diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index b236ae7..ab9e2f0 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -101,6 +101,7 @@ flowchart TD atmo -->|Screenshots on a spinning tower| rt[ring-tower] atmo -->|Hyperspace streaks, scroll warp| rl[radiant-lines] atmo -->|Hair-line explosion| lb[logo-burst] + atmo -->|Isometric figures built from tiles| il[iso-lattice] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] mark -->|Eyes that track the pointer| orb[live-orb] @@ -119,6 +120,7 @@ Disambiguation that agents get wrong: | Page leans while scrolling | `folio` | `stretchy-footer` (that one stretches, it does not tilt) | | Stars that speed up when you scroll | `radiant-lines` | `logo-burst` | | Logo explodes into lines | `logo-burst` | `ascii-logo` | +| Isometric cube, stairs, bar chart, pixel-tile figure | `iso-lattice` | `logo-burst` (lines, not a figure) | | ASCII letters that shove, scatter, and fall | `ascii-logo` | `ascii-fluid` (trails, not a wordmark) | | CRT notation, phosphor, staves | `phosphor-score` | `shader-sky` | | Chrome, liquid metal, gold ribbons | `shader-metal` | `shader-gradient` (a color wash, not a material) | @@ -139,7 +141,7 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). +Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, `IsoLattice`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `ShaderMetal` uses the same shell but is transparent: the ribbons and their shadows sit on the parent's background, so set one (`bg-background`). The ribbons cross the middle; put copy top-left. @@ -181,6 +183,7 @@ Categories match `content/docs/components/meta.json`. | `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | React + Svelte | | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | +| `iso-lattice` | Background | Isometric tiles build solid figures from the floor up | React + Svelte | | `ring-tower` | Background | three.js tower of tilted image bands; drag to spin, neighbors counter-turn | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | | `shader-anime-fire` | Shaders | Cel-shaded flames licking up from the bottom | React + Svelte | diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index ff35c1c..74273d2 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -68,6 +68,31 @@ Constants: `LIGHT_COLOR` `#3F3F46`, `DARK_COLOR` `#D6D2CA`. `DEFAULT_COLOR` is d Vanilla `LogoBurstInstance`: `{ setOptions, replay, destroy }`. Not returned to app code by the React component. +## iso-lattice + +```ts +type IsoLatticeTheme = "light" | "dark" | "auto" +type IsoLatticeShape = "cube" | "steps" | "pillars" | "coil" + +type IsoLatticeProps = { + shapes?: IsoLatticeShape[] // ["cube", "steps", "pillars", "coil"]; one item holds still + interval?: number // 4 seconds per figure + color?: string + size?: number // 0.6 of the shorter side, 0.2–1 + spacing?: number // 8 CSS px between tiles, 4–24 + glow?: number // 0.6, 0–1 + speed?: number // 1, glint and shimmer only + floor?: boolean // true + interactive?: boolean // true + theme?: IsoLatticeTheme // "auto" + className?: string +} +``` + +Constants: `LIGHT_COLOR` `#C2560C`, `DARK_COLOR` `#FFB25B`, `SHAPES`. Each build takes a fixed 1.6 s; it is not a prop. + +Vanilla `IsoLatticeInstance`: `{ setOptions, destroy }`. Not returned to app code by the React component. + ## phosphor-score ```ts @@ -92,7 +117,8 @@ type PhosphorScoreProps = { ```ts type RadiantLinesProps = { - colors?: string[] + colors?: string[] // omit → theme palette + theme?: "light" | "dark" | "auto" // "auto" starCount?: number // 420 displacement?: number // 1, values < 0 clamp to 0 containerRef?: RefObject // omit → window @@ -100,7 +126,7 @@ type RadiantLinesProps = { } ``` -`DEFAULT_COLORS`: `#FF6B4A`, `#2DD4BF`, `#FBBF24`, `#60A5FA`, `#F472B6`, `#A3E635`, `#94A3B8`. +`DEFAULT_COLORS` (dark pages): `#FF6B4A`, `#2DD4BF`, `#FBBF24`, `#60A5FA`, `#F472B6`, `#A3E635`, `#94A3B8`. `LIGHT_COLORS` (light pages): the same hues, deeper. A set `colors` wins in both themes. Instance: `{ setOptions, destroy }`. @@ -289,7 +315,8 @@ type StretchyFooterPlayDetail = { type LiveOrbVariant = "white" | "black" | "webgl" | "custom" type LiveOrbProps = { - variant?: LiveOrbVariant // "white" + variant?: LiveOrbVariant // omit → "white" on dark pages, "black" on light + theme?: "light" | "dark" | "auto" // "auto" color?: string // custom body, default #7C5CFF eyeColor?: string // custom eyes, default #FAFAFA colors?: string[] // webgl stops @@ -315,11 +342,14 @@ type AsciiLogoProps = { fit?: number // 0.82 cellSize?: number // 11 cellGap?: number // 2 - charset?: string // DEFAULT_CHARSET - threshold?: number // 0.2 + charset?: string // DEFAULT_CHARSET, cells inside the shape + edgeCharset?: string // EDGE_CHARSET, light marks on the rim + threshold?: number // 0.2, coverage a cell needs invert?: boolean // true if src set, else false - color?: string - backgroundColor?: string // "transparent" skips fill + color?: string // omit → LIGHT_INK / DARK_INK by theme + backgroundColor?: string // "transparent" + font?: string // omit → root's computed font; wrapper sets font-mono + assemble?: boolean // true, glyphs fly in on build hoverRadius?: number // 7 hoverPush?: number // 2.6 hoverEase?: number // 0.18 diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index a77ca94..1f16ee2 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -38,6 +38,34 @@ export function Mark() { Pitfalls: `color` overrides theme ink. Light filament is `#3F3F46`, dark is `#D6D2CA`. `DEFAULT_COLOR` is deprecated (it is the dark color). Reduced motion draws the settled field and skips the explosion. Click replays unless `replayOnClick={false}`. +## iso-lattice + +Background. A fixed lattice of isometric diamond tiles lights up solid figures (cube, steps, pillars, coil) on a faint floor grid, and builds each one over the last from the floor up. A level glint rises through the figure, the pointer lights the lattice like a lamp. Transparent canvas. + +Best fit: an infrastructure or developer-tool hero that wants one luminous object under the headline; a section divider with a figure that changes. + +Not this: hair-line explosion (`logo-burst`), scroll starfield (`radiant-lines`), images on a tower (`ring-tower`). + +Deps: none (WebGL). Key props: `shapes` (`["cube", "steps", "pillars", "coil"]`), `interval` (4s), `color`, `theme` (`auto`), `size` (0.6), `spacing` (8px), `glow` (0.6), `speed` (1), `floor` (true), `interactive` (true). + +```tsx +"use client" +import { IsoLattice } from "@/components/ui/iso-lattice" + +export function Hero() { + return ( +
+

Ship the build

+
+ +
+
+ ) +} +``` + +Pitfalls: the figure is centered in the canvas, so give the lattice its own box below the copy instead of centering copy over it. `color` overrides the theme amber; light is `#C2560C`, dark `#FFB25B`. Unknown names in `shapes` are dropped; an empty list falls back to all four. Reduced motion holds the first figure with no build, glint or lamp. Without WebGL the canvas stays empty. + ## phosphor-score Background. Two vertical staves. Notes fall, bloom at the playhead, then flare. Not a piano roll. @@ -71,7 +99,7 @@ Best fit: a long scrolling page that should feel like hyperspace. Not a static p Not this: `logo-burst` (one-shot explosion), `shader-gradient` (no scroll coupling). -Deps: none. Key props: `colors`, `starCount` (420), `displacement` (1), `containerRef` / Svelte `container`. +Deps: none. Key props: `colors` (omit it and the palette follows the theme), `theme`, `starCount` (420), `displacement` (1), `containerRef` / Svelte `container`. ```tsx "use client" @@ -288,7 +316,7 @@ Best fit: a mascot, an empty state, a corner presence. Not this: a full-bleed background. It is `size` pixels square (default 280). -Deps: none. The only `variant` in the registry: `"white"` (default), `"black"`, `"webgl"`, `"custom"`. Also `size`, `color`, `eyeColor`, `colors`, `interactive` (true), `blink` (true). +Deps: none. The only `variant` in the registry: `"white"`, `"black"`, `"webgl"`, `"custom"`. Omit it and the orb follows the theme (white on dark pages, black on light). Also `theme`, `size`, `color`, `eyeColor`, `colors`, `interactive` (true), `blink` (true). ```tsx "use client" @@ -303,13 +331,13 @@ Pitfalls: `color` / `eyeColor` apply only for `variant="custom"` (defaults `#7C5 ## ascii-logo -Character. Glyphs sample `text` or `src`. Hover shoves them in the `logo` phase. Clicks cycle `logo → scattered → fallen → returning → logo`. +Character. Glyphs sample `text` or `src` (4x supersampled, so rim cells get light marks and fade). They fly in on build, hover shoves them in the `logo` phase, and clicks cycle `logo → scattered → fallen → returning → logo`. The stage is transparent; ink follows the theme. Best fit: an interactive wordmark (max 5 letters) or a sampled mark. Not this: mouse-trail fluid (`ascii-fluid`). -Deps: none. Key props: `text` (`"23rd"`), `src`, `fit` (0.82), `cellSize` (11), `cellGap` (2), `charset`, `threshold` (0.2), `invert`, `color`, `backgroundColor`, hover/scatter/gravity props, `interactive` (true), `theme`, `label`, `onPhaseChange`. +Deps: none. Key props: `text` (`"23rd"`), `src`, `fit` (0.82), `cellSize` (11), `cellGap` (2), `charset`, `edgeCharset`, `threshold` (0.2), `invert`, `color`, `backgroundColor` (transparent), `font`, `assemble` (true), hover/scatter/gravity props, `interactive` (true), `theme`, `label`, `onPhaseChange`. ```tsx "use client" diff --git a/.cursor/skills/23rd/conventions.md b/.cursor/skills/23rd/conventions.md index d746f37..afb7dd0 100644 --- a/.cursor/skills/23rd/conventions.md +++ b/.cursor/skills/23rd/conventions.md @@ -100,6 +100,8 @@ Demos: - `*-demo.tsx` is for the docs site only. It is not a registry file. - Demo stages and docs examples use theme tokens (`bg-background`, `from-background`, `text-foreground`), never a hard-coded black or white stage. Check the demo in both themes before shipping. - Use `ComponentPreview`, `ComponentControls`, and `usePreviewProps` the way `shader-gradient-demo.tsx` does. +- One `ComponentPreview` per page. On a component page it moves into the full-height stage at the top of the docs, and the controls into the props panel beside it. Size the demo's root with `h-[var(--preview-h,56svh)]` so it fills the stage there and keeps 56svh anywhere else. +- Do not put a `## Preview` heading above the demo; the preview is not in the page flow. - `gooey-color-picker` has no demo file; the MDX renders `` inside `ComponentPreview`. Either pattern is acceptable. Do not publish the demo. ## Docs diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index b676a59..ee36002 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -41,6 +41,7 @@ Swap the component, keep the shell: | ASCII trails | `AsciiFluid` | `interactive` | | CRT score | `PhosphorScore` | parent `overflow-hidden`; do not expect a `d` key | | Burst, no mark | `LogoBurst` | no children | +| Glowing isometric figure | `IsoLattice` | lattice in its own box under the copy | | Burst around a logo | `LogoBurst` | pass the mark as `children` | Svelte: `import ShaderGradient from "$lib/components/ui/shader-gradient.svelte"` and `class` on the section if you style the component itself. diff --git a/README.md b/README.md index b5df111..60d50c2 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ npx skills add radiumcoders/23rd.dev --skill 23rd -g | [Dithered 404](https://23rd.dev/docs/components/dithered-404) | `@23rd/dithered-404` | Bayer-pixel 404; a fireball cursor scorches it into embers and smoke, then it reforms | | [Folio](https://23rd.dev/docs/components/folio) | `@23rd/folio` | The page leans back in perspective while you scroll, blurs, then springs flat | | [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex, joined by an SVG gooey filter | +| [Iso Lattice](https://23rd.dev/docs/components/iso-lattice) | `@23rd/iso-lattice` | Glowing isometric tiles build solid figures, one over the last, from the floor up | | [Live Orb](https://23rd.dev/docs/components/live-orb) | `@23rd/live-orb` | An evenly lit sphere with two capsule eyes that follow the pointer | | [Logo Burst](https://23rd.dev/docs/components/logo-burst) | `@23rd/logo-burst` | Hair-line tentacles explode from the center, then breathe, light and dark | | [Phosphor Score](https://23rd.dev/docs/components/phosphor-score) | `@23rd/phosphor-score` | Vertical CRT sheet music; notes fall, bloom at the playhead, then exit in a flare | diff --git a/app/docs/[[...slug]]/page.tsx b/app/docs/[[...slug]]/page.tsx index 7fdb9cc..ec2b448 100644 --- a/app/docs/[[...slug]]/page.tsx +++ b/app/docs/[[...slug]]/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next" import { notFound } from "next/navigation" import { DocsPager } from "@/components/docs-pager" +import { DocsStage } from "@/components/docs-stage" import { DocsToc } from "@/components/docs-toc" import { JsonLd } from "@/components/json-ld" import { getMDXComponents } from "@/components/mdx" @@ -29,55 +30,85 @@ export default async function Page(props: { const component = isComponentPage(params.slug) const markdown = await page.data.getText("processed") - return ( -
-
- + ) + + const article = ( +
+
+
+

+ {page.data.title} +

+ +
+ {page.data.description ? ( +

+ {page.data.description} +

+ ) : null} +
+
+ -
-
-
-

- {page.data.title} -

- +
+ +
+ ) + + // A component page: the preview fills a stage at the top, then the page. + // The outline rides inside the sheet, beside the article. + if (component) { + return ( + + {/* Same gutter and radius as the stage above it. */} +
+
+ {jsonLd} +
+ {article} +
- {page.data.description ? ( -

- {page.data.description} -

- ) : null} - -
-
- -
+
+ + ) + } + + return ( +
+
+ {jsonLd} + {article}
@@ -97,7 +128,12 @@ export async function generateMetadata(props: { const path = docsPath(params.slug) const extraKeywords = isComponentPage(params.slug) - ? [page.data.title, "shadcn component", "React component", "Svelte component"] + ? [ + page.data.title, + "shadcn component", + "React component", + "Svelte component", + ] : [page.data.title] return buildPageMetadata({ diff --git a/app/globals.css b/app/globals.css index 6581d78..600f796 100644 --- a/app/globals.css +++ b/app/globals.css @@ -120,7 +120,7 @@ /* * Firefox stops painting the rest of the page while a view transition runs, - * so the sidebar marker's whole flight happens unseen and it just jumps. + * so the sidebar chip's whole slide happens unseen and it just jumps. * There the transition ends at once and the new page fades in as a plain * animation, which leaves everything else live. */ @@ -186,7 +186,7 @@ --wash: #e6e8e9; --rule: #d8dadc; - /* Subpixel triad: the "this one is on" indicator. */ + /* Subpixel triad: the outline's click pulse and the chart colors. */ --triad-r: #ff3b3b; --triad-g: #1fc760; --triad-b: #3563ff; @@ -276,6 +276,38 @@ --color-fd-ring: var(--ring); } +/* + * Docs, inside out: the frame (sidebar, bench) takes the panel tone and the + * panels (stage, sheet) take the bench tone, so the work sits on a raised + * surface. The roles derived from --bench resolve where they are declared, + * so they are declared again here to pick up the swapped value. + */ +.docs-flip { + --bench: #ffffff; + --sheet: #eeefef; + --screen: #eeefef; + --background: var(--bench); + --primary-foreground: var(--bench); + --sidebar: var(--bench); + --sidebar-primary-foreground: var(--bench); + --color-fd-background: var(--bench); + --color-fd-primary-foreground: var(--bench); +} + +.dark .docs-flip { + --bench: #070706; + --sheet: #0f0f0e; + --screen: #0f0f0e; +} + +@media (prefers-color-scheme: dark) { + :root:not(.light) .docs-flip { + --bench: #070706; + --sheet: #0f0f0e; + --screen: #0f0f0e; + } +} + .dark { --destructive: oklch(0.704 0.191 22.216); } diff --git a/components/component-controls.tsx b/components/component-controls.tsx index ee108a0..f4f760a 100644 --- a/components/component-controls.tsx +++ b/components/component-controls.tsx @@ -2,18 +2,22 @@ import { useCallback, + useContext, + useEffect, useId, useMemo, + useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react" import { RiAddLine, RiRefreshLine } from "@remixicon/react" +import { createPortal } from "react-dom" +import { PreviewStageContext } from "@/components/component-preview" import { CopyButton } from "@/components/copy-button" import { trackEvent } from "@/components/tracwell-analytics" import { Button } from "@/components/ui/button" -import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" function toHex6(value: string) { @@ -61,6 +65,17 @@ function formatJsxLiteral(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((item) => formatJsxLiteral(item)).join(", ")}]` } + if (value && typeof value === "object") { + const entries = Object.entries(value).filter(([, v]) => v !== undefined) + if (entries.length === 0) return "{}" + const body = entries + .map(([key, v]) => { + const name = /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) + return `${name}: ${formatJsxLiteral(v)}` + }) + .join(", ") + return `{ ${body} }` + } return JSON.stringify(value) } @@ -105,10 +120,12 @@ export type ComponentControlsProps = { } /** Row chrome shared by every control, and applied to custom rows demos pass in. */ -const rowClassName = "rounded-lg border bg-background" +const rowClassName = "rounded-lg bg-background" /** - * Live prop controls. Directly after ComponentPreview it joins the preview's + * Live prop controls. On a component page they move into the props panel + * beside the stage (a portal, so they keep driving the demo you watch). + * Anywhere else, directly after ComponentPreview they join the preview's * frame, so the props read as the screen's control strip. */ export function ComponentControls({ @@ -120,21 +137,43 @@ export function ComponentControls({ component, snippetProps, }: ComponentControlsProps) { + const stage = useContext(PreviewStageContext) + const onControls = stage?.onControls + useEffect(() => { + if (!onControls) return + onControls(true) + return () => onControls(false) + }, [onControls]) + const snippet = component ? formatComponentSnippet(component, snippetProps ?? {}) : "" + const docked = !!stage - return ( + const controls = (
-
- {title} +
+ {title} {snippet || onReset ? (
{onReset ? ( @@ -171,14 +210,21 @@ export function ComponentControls({
:not([data-control])]:rounded-lg [&>:not([data-control])]:border [&>:not([data-control])]:bg-background [&>:not([data-control])]:px-3 [&>:not([data-control])]:py-2" + "flex flex-col gap-2 px-3 py-3", + // Under the stage on a mid-width screen the panel is wide; two + // columns keep each label near its control. Beside it, one. + docked && "md:grid md:grid-cols-2 md:gap-x-3 xl:flex", + "[&>:not([data-control])]:rounded-lg [&>:not([data-control])]:bg-background [&>:not([data-control])]:px-3 [&>:not([data-control])]:py-2" )} > {children}
) + + if (!stage) return controls + if (!stage.controlsSlot) return null + return createPortal(controls, stage.controlsSlot) } function formatValue(value: number) { @@ -205,7 +251,32 @@ function snapToStep(raw: number, min: number, max: number, step: number) { return Number(Math.min(max, Math.max(min, snapped)).toFixed(precision)) } -/** A whole-row slider: drag anywhere on the row; the fill is the value. */ +/** + * How far, in px, the row can stretch past its end; it never quite gets + * there. The rows' side padding leaves exactly this much room, so a scroll + * container around them never clips the stretch. + */ +const STRETCH_MAX = 12 +/** Release spring: a quick overshoot, then rest. */ +const STRETCH_RELEASE = "transform 520ms cubic-bezier(0.34, 1.56, 0.64, 1)" + +/** + * Rubber band: the further past the end, the less each extra pixel pulls, + * easing toward STRETCH_MAX. + */ +function rubberBand(overshoot: number) { + return ( + Math.sign(overshoot) * + STRETCH_MAX * + (1 - Math.exp(-Math.abs(overshoot) / 60)) + ) +} + +/** + * A whole-row slider: drag anywhere on the row; the fill is the value. + * Dragged past either end, the row stretches after the pointer like + * rubber, then springs back on release. + */ export function ControlSlider({ label, value, @@ -218,30 +289,67 @@ export function ControlSlider({ }: ControlSliderProps) { const id = useId() const progress = max === min ? 0 : (value - min) / (max - min) + const rowRef = useRef(null) + // The row's box when the drag began. Measured once: while stretched the + // live box is scaled, and measuring it would feed the stretch back in. + const boxRef = useRef<{ left: number; width: number } | null>(null) + + const stretch = useCallback((px: number, width: number) => { + const row = rowRef.current + if (!row) return + if (px === 0 || width <= 0) { + row.style.transform = "" + return + } + const k = Math.abs(px) / width + row.style.transition = "none" + row.style.transformOrigin = px > 0 ? "left center" : "right center" + row.style.transform = `scale(${1 + k}, ${1 - k * 0.6})` + }, []) const commitFromClientX = useCallback( - (element: HTMLElement, clientX: number) => { - const { left, width } = element.getBoundingClientRect() - if (width <= 0) return - const ratio = Math.min(1, Math.max(0, (clientX - left) / width)) + (clientX: number) => { + const box = boxRef.current + if (!box || box.width <= 0) return + const raw = (clientX - box.left) / box.width + const ratio = Math.min(1, Math.max(0, raw)) onChange(snapToStep(min + ratio * (max - min), min, max, step)) + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + const overshoot = + raw < 0 + ? clientX - box.left + : raw > 1 + ? clientX - (box.left + box.width) + : 0 + stretch(rubberBand(overshoot), box.width) }, - [max, min, onChange, step] + [max, min, onChange, step, stretch] ) function onPointerDown(event: ReactPointerEvent) { if (event.button !== 0) return event.currentTarget.setPointerCapture(event.pointerId) - commitFromClientX(event.currentTarget, event.clientX) + const { left, width } = event.currentTarget.getBoundingClientRect() + boxRef.current = { left, width } + commitFromClientX(event.clientX) } function onPointerMove(event: ReactPointerEvent) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - commitFromClientX(event.currentTarget, event.clientX) + commitFromClientX(event.clientX) + } + + function onRelease() { + boxRef.current = null + const row = rowRef.current + if (!row || !row.style.transform) return + row.style.transition = STRETCH_RELEASE + row.style.transform = "" } return (
onChange(!checked)} className={cn( rowClassName, - "flex min-h-10 items-center justify-between gap-4 px-3 py-2", + "group/switch flex min-h-10 w-full cursor-pointer items-center justify-between gap-4 px-3 py-2 text-start transition-colors outline-none hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring/40", className )} > -
- + + {label} {description ? ( -

{description}

+ {description} ) : null} -
- -
+ + + {/* Quiet by design: the knob carries the state, dim when off and + full ink when on, rather than the whole pill lighting up. */} + + + ) } @@ -390,11 +508,14 @@ export function ControlColors({ className, }: ControlColorsProps) { const fitted = useMemo( - () => palettes.slice(0, 4).map((palette) => fitPalette(palette, colors.length)), + () => + palettes.slice(0, 4).map((palette) => fitPalette(palette, colors.length)), [palettes, colors.length] ) - const activeIndex = fitted.findIndex((palette) => colorsEqual(palette, colors)) + const activeIndex = fitted.findIndex((palette) => + colorsEqual(palette, colors) + ) const [customOpen, setCustomOpen] = useState(activeIndex === -1) return ( diff --git a/components/component-preview.tsx b/components/component-preview.tsx index 2f8f8d2..c8d8ebc 100644 --- a/components/component-preview.tsx +++ b/components/component-preview.tsx @@ -1,4 +1,14 @@ -import type { CSSProperties, ReactNode } from "react" +"use client" + +import { + createContext, + useContext, + useLayoutEffect, + useRef, + type CSSProperties, + type ReactNode, +} from "react" +import { createPortal } from "react-dom" import { cn } from "@/lib/utils" @@ -15,34 +25,79 @@ interface ComponentPreviewProps { align?: "center" | "start" | "end" } +/** + * Set by DocsStage on a component page. `slot` is the stage the preview + * moves into and `controlsSlot` the props panel beside it, where + * ComponentControls moves; both are null for the first render, before the + * stage has mounted. `onControls` tells the stage whether the page has + * controls, so the panel only shows when there is something in it. + * Outside a component page there is no stage, and both render in place. + */ +export type PreviewStage = { + slot: HTMLElement | null + controlsSlot: HTMLElement | null + onControls: (present: boolean) => void +} + +export const PreviewStageContext = createContext(null) + /** Inside the stage, `bg-background` is the screen, not the sheet. */ const screenTokens = { "--background": "var(--screen)" } as CSSProperties /** - * The live stage: a screen set in a frame. When ComponentControls follows, - * the two share one frame, with the props underneath the screen. + * The live stage: a screen set in a frame. On a component page it renders + * into the stage at the top (a portal, so the demo's state and its + * controls stay wired up) and fills it; anywhere else, in place, where + * ComponentControls directly after it joins its frame. */ -export function ComponentPreview({ +export function ComponentPreview(props: ComponentPreviewProps) { + const stage = useContext(PreviewStageContext) + if (!stage) return + if (!stage.slot) return null + return createPortal(, stage.slot) +} + +function PreviewFrame({ children, className, stageClassName, title, name, align = "center", -}: ComponentPreviewProps) { + fill = false, +}: ComponentPreviewProps & { fill?: boolean }) { const label = title ?? name + const screenRef = useRef(null) + + // Demos size themselves with `var(--preview-h, 56svh)`. Filling the stage, + // the screen hands them its own height, so a demo fills it exactly. + useLayoutEffect(() => { + const screen = screenRef.current + if (!fill || !screen) return + const sync = () => + screen.style.setProperty("--preview-h", `${screen.clientHeight}px`) + sync() + const ro = new ResizeObserver(sync) + ro.observe(screen) + return () => ro.disconnect() + }, [fill]) return (
{children} diff --git a/components/demos.tsx b/components/demos.tsx index 12bd367..c56ca4a 100644 --- a/components/demos.tsx +++ b/components/demos.tsx @@ -46,6 +46,12 @@ export const GooeyColorPicker = lazyDemo(() => ) ) +export const IsoLatticeDemo = lazyDemo(() => + import("@/registry/iso-lattice/iso-lattice-demo").then( + (m) => m.IsoLatticeDemo + ) +) + export const LiveOrbDemo = lazyDemo(() => import("@/registry/live-orb/live-orb-demo").then((m) => m.LiveOrbDemo) ) diff --git a/components/docs-shell.tsx b/components/docs-shell.tsx index 2b1ae25..5403697 100644 --- a/components/docs-shell.tsx +++ b/components/docs-shell.tsx @@ -1,9 +1,14 @@ "use client" -import type { CSSProperties, ReactNode } from "react" +import { + useLayoutEffect, + useRef, + type CSSProperties, + type ReactNode, +} from "react" import Link from "next/link" import { RiHeartFill } from "@remixicon/react" -import { motion } from "motion/react" +import { motion, useReducedMotion } from "motion/react" import { DocsSidebar } from "@/components/docs-sidebar" import { DocsSidebarTrigger } from "@/components/docs-sidebar-trigger" @@ -13,6 +18,7 @@ import { SearchTrigger } from "@/components/search-trigger" import { ThemeToggle } from "@/components/theme-toggle" import { FOOTER_LINKS, SUPPORT_EMAIL } from "@/lib/site" import { FrameworkProvider } from "@/lib/framework" +import { cn } from "@/lib/utils" import { Sidebar, SidebarContent, @@ -65,28 +71,19 @@ function SidebarBody({ }) { return ( <> - - + +
+ + +
- + - - -
- - - - - Sponsor - -
+ {/* Fine print scrolls with the list, so the pinned row stays one line. */} +
+ + + + + + Sponsor + ) } +/** The sidebar's width in px; `w-60`. */ +const SIDEBAR_WIDTH = 240 +/** Release the preview this long after a toggle if `transitionend` never comes. */ +const SLIDE_FALLBACK_MS = 1000 + +/** + * Holds the live preview at one width while the sidebar slides, centred in + * the stage, which clips it. The stage itself eases with the layout, but + * the component inside is resized once, when the slide ends, instead of on + * every frame (each resize redraws or re-lays out the component, and a + * canvas clears until its next frame). Returns the release. + */ +function holdPreview(main: HTMLElement, opening: boolean) { + const screen = main.querySelector( + '[data-slot="docs-stage-screen"]' + ) + if (!screen) return () => {} + // The stage gains or loses the sidebar's width; hold the wider of the + // two so the preview never comes up short of its frame. + const width = screen.clientWidth + (opening ? 0 : SIDEBAR_WIDTH) + const held = Array.from(screen.children) as HTMLElement[] + for (const el of held) { + el.style.width = `${width}px` + el.style.maxWidth = "none" + el.style.marginLeft = `calc(50% - ${width / 2}px)` + } + return () => { + for (const el of held) { + el.style.width = "" + el.style.maxWidth = "" + el.style.marginLeft = "" + } + } +} + +/** + * From `md` up the page tree sits at the left, and its toggle (or Ctrl+B) + * folds it away entirely, leaving a floating button over the corner of + * the page so a component's stage can take the full width. + * + * The aside's width eases and the panel inside rides its right edge, so the + * page reflows smoothly beside it; only the live preview is held still + * until the slide lands (`holdPreview`). + */ +function DesktopFrame({ + tree, + githubStars, + children, +}: { + tree: RootNode + githubStars: number | null + children: ReactNode +}) { + const { open, isMobile } = useSidebar() + const reduce = useReducedMotion() ?? false + const asideRef = useRef(null) + const mainRef = useRef(null) + const wasOpen = useRef(open) + + useLayoutEffect(() => { + if (wasOpen.current === open) return + wasOpen.current = open + const aside = asideRef.current + const main = mainRef.current + if (!aside || !main || reduce || isMobile) return + const release = holdPreview(main, open) + const done = (e: TransitionEvent) => { + if (e.target === aside && e.propertyName === "width") release() + } + aside.addEventListener("transitionend", done) + const id = window.setTimeout(release, SLIDE_FALLBACK_MS) + return () => { + aside.removeEventListener("transitionend", done) + window.clearTimeout(id) + release() + } + }, [open, reduce, isMobile]) + + return ( +
+ + {open ? null : ( + // Waits for the sidebar to leave before it shows. +
+ +
+ )} +
+ {children} +
+
+ ) +} + /** Below `md` the page tree lives in a sheet opened from the top bar. */ function MobileSidebar({ tree, @@ -145,36 +273,19 @@ export function DocsShell({ -
- {/* - * Motion measures shared layouts in page coordinates, and Next - * scrolls to the top between its before/after measurements, so - * a sticky sidebar's marker would fly in from off-screen. A - * `layoutScroll` fixed box is a scroll root it measures in the - * viewport instead; `contain: layout` pins that box to the aside. - */} - -
-
- - -
- - -
- {children} -
-
+ +
+ + +
+ + +
+ {children} +
) diff --git a/components/docs-sidebar.tsx b/components/docs-sidebar.tsx index dc4d02a..d9f24d3 100644 --- a/components/docs-sidebar.tsx +++ b/components/docs-sidebar.tsx @@ -4,7 +4,6 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { - arc, cancelFrame, frame, frameData, @@ -12,13 +11,11 @@ import { motion, time, useReducedMotion, - type MotionPath, type ValueTransition, } from "motion/react" import { DocsSidebarTrigger } from "@/components/docs-sidebar-trigger" import { Logo } from "@/components/logo" -import { Triad } from "@/components/triad" import { Sidebar, SidebarContent, @@ -63,56 +60,34 @@ function isCurrent(pathname: string, url: string) { } const headingClassName = - "mt-6 mb-1 h-auto truncate px-2 py-1 font-mono text-[10.5px] font-normal tracking-[0.18em] text-muted-foreground/80 uppercase first:mt-0" + "relative z-10 mt-4 mb-0.5 h-6 truncate rounded-md px-2.5 text-[12.5px] font-medium text-foreground first:mt-0" -const markSpring = { +/** Settles without overshoot: the chip lands on its row and stays. */ +const chipSpring = { type: "spring" as const, - stiffness: 460, - damping: 34, - mass: 0.7, + stiffness: 520, + damping: 44, + mass: 0.8, } -/** - * The marker bows left, toward the sidebar's edge. Only ~24px sit between - * it and the scroll area's clip, so the bow is capped in pixels instead of - * growing with the trip like a plain `arc()` would. - */ -const MARK_BOW = 18 -const MARK_STRENGTH = 0.3 - -const markPath: MotionPath = { - interpolateProjection(delta) { - // `translate` is where the marker starts relative to its new row: - // negative means the old row sat above, so it is travelling down. - const distance = Math.hypot(delta.x.translate, delta.y.translate) - // A quadratic arc peaks at half its control-point offset. - const strength = Math.min(MARK_STRENGTH, (2 * MARK_BOW) / distance) - // `arc()` bends relative to travel, so pick the side per trip. - const direction = delta.y.translate < 0 ? "ccw" : "cw" - return arc({ strength, direction }).interpolateProjection(delta) - }, - animateVisualElement: (...args) => - arc({ strength: MARK_STRENGTH }).animateVisualElement(...args), -} - -/** The longest step, in ms, the marker's clock takes in one frame. */ -const MARK_MAX_STEP = 1000 / 60 +/** The longest step, in ms, the chip's clock takes in one frame. */ +const CHIP_MAX_STEP = 1000 / 60 /** * Motion's frame loop, on a clock that never jumps more than - * `MARK_MAX_STEP` per frame. Firefox stalls ~100ms swapping the page right - * after the marker takes off, and on the real clock the spring spends that - * stall mid-flight, so its first frame lands well down the arc. Here a - * stalled frame just pauses the flight. + * `CHIP_MAX_STEP` per frame. Firefox stalls ~100ms swapping the page right + * after the chip sets off, and on the real clock the spring spends that + * stall mid-flight, so its first frame lands most of the way there. Here a + * stalled frame just pauses the move. */ -const markDriver: NonNullable = (update) => { +const chipDriver: NonNullable = (update) => { let last: number | undefined let clock: number | undefined const step = ({ timestamp }: { timestamp: number }) => { clock = last === undefined || clock === undefined ? timestamp - : clock + Math.min(timestamp - last, MARK_MAX_STEP) + : clock + Math.min(timestamp - last, CHIP_MAX_STEP) last = timestamp update(clock) } @@ -125,56 +100,71 @@ const markDriver: NonNullable = (update) => { } } -/** Room the marker takes before the open page's name. */ -const MARK_GUTTER = "translate-x-[21px]" - /** - * The open page's marker. Every page shares one `layoutId`, so when the - * open page changes Motion flies the marker along an arc to the new row. - * It hangs off the `
  • `, not the button, whose `overflow-hidden` would - * clip it mid-flight. + * The open page's row is a scrap of the sheet the page sits on: the same + * surface and hairline edge, so the row and the panel beside it read as one + * thing. Every page shares one `layoutId`, so the chip slides to the new + * row when the page changes. Hover shows a fainter one. + * + * The chip sits at z-0 and every row and heading at z-10. Without that, + * DOM order decides: sliding down, the chip belongs to a later row and + * would paint over the text of every row it passes. */ -function ActiveMark() { +function ActiveChip() { const reduce = useReducedMotion() ?? false return ( - - + /> ) } +/** Scrolls the list just enough to show `row`, without moving the page. */ +function reveal(row: HTMLElement) { + const list = row.closest('[data-sidebar="content"]') + if (!list) return + const top = row.offsetTop - list.offsetTop + const margin = 32 + if (top < list.scrollTop + margin) { + list.scrollTop = Math.max(0, top - margin) + } else if ( + top + row.offsetHeight > + list.scrollTop + list.clientHeight - margin + ) { + list.scrollTop = top + row.offsetHeight - list.clientHeight + margin + } +} + function PageItem({ node, pathname }: { node: PageNode; pathname: string }) { const active = isCurrent(pathname, node.url) + const rowRef = React.useRef(null) + + // Opening a page from search or a link keeps its row in sight. + React.useLayoutEffect(() => { + if (active && rowRef.current) reveal(rowRef.current) + }, [active]) return ( - - {active ? : null} + + {active ? : null} } isActive={active} - className="h-8 gap-0 rounded-lg bg-transparent px-2 font-normal text-foreground/70 transition-colors duration-150 hover:bg-transparent hover:text-foreground active:bg-transparent data-active:bg-transparent data-active:font-medium data-active:text-foreground data-active:hover:bg-transparent" + className={cn( + "relative z-10 h-7 rounded-md bg-transparent px-2.5 text-[13.5px] font-normal text-muted-foreground transition-colors duration-150", + "hover:bg-[color-mix(in_oklab,var(--sheet)_70%,transparent)] hover:text-foreground active:bg-transparent", + "data-active:bg-transparent data-active:font-normal data-active:text-foreground data-active:hover:bg-transparent" + )} > - {/* The name steps aside for the marker instead of jumping. */} - - {node.name} - + {node.name} ) @@ -238,13 +228,13 @@ function NavItems({ }) } -/** Scopes the marker's `layoutId` so the desktop and mobile lists don't share one. */ +/** Scopes the chip's `layoutId` so the desktop and mobile lists don't share one. */ function NavList({ nodes, pathname }: { nodes: TreeNode[]; pathname: string }) { const id = React.useId() return ( - + diff --git a/components/docs-stage.tsx b/components/docs-stage.tsx new file mode 100644 index 0000000..f6d53fe --- /dev/null +++ b/components/docs-stage.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useCallback, useMemo, useState, type ReactNode } from "react" + +import { PreviewStageContext } from "@/components/component-preview" + +/** + * Component page layout: the live preview fills a full-height stage at the + * top, edge to edge, with the props in a panel beside it, so you can tune + * the component while you watch it. The page (`children`) follows below. + * The page's ComponentPreview and ComponentControls move themselves in. + */ +export function DocsStage({ children }: { children: ReactNode }) { + const [slot, setSlot] = useState(null) + const [controlsSlot, setControlsSlot] = useState(null) + const [hasControls, setHasControls] = useState(false) + const onControls = useCallback( + (present: boolean) => setHasControls(present), + [] + ) + const stage = useMemo( + () => ({ slot, controlsSlot, onControls }), + [slot, controlsSlot, onControls] + ) + + return ( + + {/* + * One gutter all round and between: 0.5rem on mobile, 0.75rem from + * `md`. Side by side from `xl`, one screen tall, the panel scrolling + * on its own; narrower, the panel follows the stage, which keeps a + * screen to itself (less the 3.5rem top bar on mobile). + */} +
    +
    +
    +
    +
    + {children} + + ) +} diff --git a/components/docs-toc.tsx b/components/docs-toc.tsx index f04fc1f..1d5906d 100644 --- a/components/docs-toc.tsx +++ b/components/docs-toc.tsx @@ -3,14 +3,27 @@ import Link from "next/link" import { RiArrowRightUpLine, RiHeartFill } from "@remixicon/react" import { DocsTocOutline } from "@/components/docs-toc-outline" +import { cn } from "@/lib/utils" /** - * Right rail on the bench, outside the page sheet: the animated page outline - * (no scroll tracking), with the sponsor slot underneath. + * Right rail: the animated page outline (no scroll tracking), with the + * sponsor slot underneath. On the bench beside the sheet, or inside it on a + * component page. */ -export function DocsToc({ items }: { items: TOCItemType[] }) { +export function DocsToc({ + items, + className, +}: { + items: TOCItemType[] + className?: string +}) { return ( -
    \n", + "content": "\n\n\n\n\n \n \n
  • \n", "type": "registry:file", "target": "src/lib/components/ui/ascii-fluid.svelte" } diff --git a/public/r/ascii-fluid.json b/public/r/ascii-fluid.json index 47f1033..4312dc6 100644 --- a/public/r/ascii-fluid.json +++ b/public/r/ascii-fluid.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-fluid/ascii-fluid.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiFluidTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type AsciiFluidOptions = {\n /**\n * Brightness ramp (sparse → dense). Default is a full letter/symbol map.\n */\n charset?: string\n /** Glyph cell size in CSS pixels. Default `12` */\n cellSize?: number\n /** Ink color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Default follows theme. */\n backgroundColor?: string\n /** Mouse trail / fluid force. Default `1` */\n force?: number\n /** How quickly trails fade (higher = vanish sooner). Default `0.05` */\n dissipation?: number\n /** Trail brush size 0–1. Default `0.55` */\n brush?: number\n /** Soft ambient swirl when idle. Default `true` */\n animate?: boolean\n /** Follow pointer. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiFluidTheme\n}\n\nexport type AsciiFluidInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Sparse → dense brightness ramp (letters + symbols). */\nexport const DEFAULT_CHARSET =\n \" .'`^\\\",:;Il!i><~+_-?][}{1)(|\\\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$\"\n\nconst LIGHT = { ink: \"#18181b\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#e4e4e7\", paper: \"#09090b\" }\n\nconst VERT = `\nattribute vec2 a_position;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_position * 0.5 + 0.5;\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\n// Velocity / dye stored biased in RGBA8: enc(v) = v * 0.05 + 0.5\nconst FRAG_SPLAT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_target;\nuniform vec2 u_point;\nuniform vec3 u_color;\nuniform float u_radius;\nuniform float u_aspect;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 p = v_uv - u_point;\n p.x *= u_aspect;\n float d = exp(-dot(p, p) / max(u_radius, 0.0001));\n vec3 base = texture2D(u_target, v_uv).xyz;\n if (u_velocityField > 0.5) {\n vec2 next = dec(base.xy) + u_color.xy * d;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(base + u_color * d, 1.0);\n }\n}\n`\n\nconst FRAG_ADVECT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform sampler2D u_source;\nuniform vec2 u_texel;\nuniform float u_dt;\nuniform float u_dissipation;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vec2 coord = v_uv - u_dt * vel * u_texel * 110.0;\n vec4 src = texture2D(u_source, clamp(coord, 0.0, 1.0));\n if (u_velocityField > 0.5) {\n vec2 next = dec(src.xy) * u_dissipation;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(src.xyz * u_dissipation, 1.0);\n }\n}\n`\n\nconst FRAG_DIVERGENCE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\n\nvoid main() {\n float L = dec(texture2D(u_velocity, v_uv - vec2(u_texel.x, 0.0)).xy).x;\n float R = dec(texture2D(u_velocity, v_uv + vec2(u_texel.x, 0.0)).xy).x;\n float B = dec(texture2D(u_velocity, v_uv - vec2(0.0, u_texel.y)).xy).y;\n float T = dec(texture2D(u_velocity, v_uv + vec2(0.0, u_texel.y)).xy).y;\n float div = 0.5 * ((R - L) + (T - B));\n gl_FragColor = vec4(div * 0.05 + 0.5, 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_PRESSURE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_divergence;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nfloat enc1(float v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n float C = dec1(texture2D(u_divergence, v_uv).x);\n float p = (L + R + B + T - C) * 0.25;\n gl_FragColor = vec4(enc1(p), 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_GRADIENT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vel -= vec2(R - L, T - B) * 0.5;\n gl_FragColor = vec4(enc(vel), 0.5, 1.0);\n}\n`\n\nconst FRAG_DISPLAY = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_dye;\nuniform sampler2D u_atlas;\nuniform vec2 u_resolution;\nuniform vec2 u_cell;\nuniform float u_charCount;\nuniform vec3 u_ink;\nuniform vec3 u_paper;\nuniform float u_time;\nuniform float u_animate;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);\n}\n\nvoid main() {\n vec2 pixel = v_uv * u_resolution;\n vec2 cell = floor(pixel / u_cell);\n vec2 cellUv = (cell + 0.5) * u_cell / u_resolution;\n\n float dens = clamp(texture2D(u_dye, cellUv).x, 0.0, 1.0);\n\n // Soft neighborhood sample for a low-res glow halo under the glyphs\n vec2 texel = u_cell / u_resolution;\n float glow =\n dens * 0.40 +\n texture2D(u_dye, cellUv + vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv + vec2(0.0, texel.y)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2(0.0, texel.y)).x * 0.15;\n glow = clamp(glow, 0.0, 1.0);\n glow = pow(glow, 1.35);\n\n float lit = dens;\n if (u_animate > 0.5) {\n float flicker = hash21(cell + floor(u_time * 10.0)) - 0.5;\n lit = clamp(lit + flicker * 0.05, 0.0, 1.0);\n }\n\n float idx = min(floor(lit * (u_charCount - 0.001)), u_charCount - 1.0);\n vec2 local = fract(pixel / u_cell);\n float u0 = (idx + local.x) / u_charCount;\n float glyph = texture2D(u_atlas, vec2(u0, local.y)).r;\n\n float alpha = glyph * smoothstep(0.02, 0.12, dens);\n\n // Paper → soft ink wash → sharp ASCII on top\n float wash = glow * 0.22;\n vec3 col = mix(u_paper, u_ink, wash);\n col = mix(col, u_ink, clamp(alpha, 0.0, 1.0));\n gl_FragColor = vec4(col, 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.1, 0.1, 0.12]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiFluidTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nfunction createProgram(\n gl: WebGLRenderingContext,\n vs: WebGLShader,\n fragSource: string\n) {\n const fs = compile(gl, gl.FRAGMENT_SHADER, fragSource)\n if (!fs) return null\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(fs)\n return null\n }\n return { program, fs }\n}\n\ntype FBO = {\n tex: WebGLTexture\n fbo: WebGLFramebuffer\n w: number\n h: number\n}\n\nfunction createFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n): FBO | null {\n const tex = gl.createTexture()\n const fbo = gl.createFramebuffer()\n if (!tex || !fbo) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n w,\n h,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n null\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)\n gl.framebufferTexture2D(\n gl.FRAMEBUFFER,\n gl.COLOR_ATTACHMENT0,\n gl.TEXTURE_2D,\n tex,\n 0\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n return { tex, fbo, w, h }\n}\n\nfunction createDoubleFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n) {\n const a = createFBO(gl, w, h, filter)\n const b = createFBO(gl, w, h, filter)\n if (!a || !b) return null\n return {\n read: a,\n write: b,\n swap() {\n const t = this.read\n this.read = this.write\n this.write = t\n },\n }\n}\n\nfunction buildAtlas(\n gl: WebGLRenderingContext,\n charset: string\n): { tex: WebGLTexture; count: number } | null {\n const count = Math.max(charset.length, 1)\n const size = 64\n const atlasCanvas = document.createElement(\"canvas\")\n atlasCanvas.width = size * count\n atlasCanvas.height = size\n const ctx = atlasCanvas.getContext(\"2d\")\n if (!ctx) return null\n ctx.fillStyle = \"#000\"\n ctx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height)\n ctx.fillStyle = \"#fff\"\n ctx.font = `700 ${Math.floor(size * 0.72)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n for (let i = 0; i < count; i++) {\n const ch = charset[i] ?? \" \"\n if (ch === \" \") continue\n ctx.fillText(ch, size * (i + 0.5), size * 0.55)\n }\n\n const tex = gl.createTexture()\n if (!tex) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n atlasCanvas\n )\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n return { tex, count }\n}\n\n/**\n * ASCII fluid background: pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n *\n * The simulation stops while the canvas is off screen or the tab is hidden.\n * Under `prefers-reduced-motion` it holds a still frame and only redraws\n * when its size, theme or options change. It recovers from a lost WebGL\n * context.\n */\nexport function createAsciiFluid(\n canvas: HTMLCanvasElement,\n initial: AsciiFluidOptions = {}\n): AsciiFluidInstance | null {\n let options: Required<\n Pick<\n AsciiFluidOptions,\n | \"charset\"\n | \"cellSize\"\n | \"force\"\n | \"dissipation\"\n | \"brush\"\n | \"animate\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiFluidOptions = {\n charset: DEFAULT_CHARSET,\n cellSize: 12,\n force: 1,\n dissipation: 0.05,\n brush: 0.55,\n animate: true,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = {\n x: 0.5,\n y: 0.5,\n dx: 0,\n dy: 0,\n moved: false,\n inside: false,\n }\n let reduce = false\n let currentCharset = \"\"\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n preserveDrawingBuffer: false,\n })\n if (!gl) return null\n\n type Program = NonNullable>\n type DoubleFBO = NonNullable>\n\n // GL resources live in `let`s so a restored context can rebuild them.\n let vs: WebGLShader\n let splat: Program\n let advect: Program\n let divergence: Program\n let pressure: Program\n let gradient: Program\n let display: Program\n let buf: WebGLBuffer | null\n let velocity: DoubleFBO\n let dye: DoubleFBO\n let pressureFbo: DoubleFBO\n let divergenceFbo: FBO\n let atlas: { tex: WebGLTexture; count: number }\n\n const bindQuad = (program: WebGLProgram) => {\n gl.useProgram(program)\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n }\n\n const SIM = 180\n\n const blit = (target: FBO | null) => {\n if (target) {\n gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)\n gl.viewport(0, 0, target.w, target.h)\n } else {\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n }\n\n const clearFbo = (fbo: FBO, r = 0, g = 0, b = 0) => {\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.fbo)\n gl.viewport(0, 0, fbo.w, fbo.h)\n gl.clearColor(r, g, b, 1)\n gl.clear(gl.COLOR_BUFFER_BIT)\n }\n\n // Builds every GL resource with an empty fluid. Runs at startup and again\n // when a lost context is restored; returns false if anything fails.\n const initGl = () => {\n const nextVs = compile(gl, gl.VERTEX_SHADER, VERT)\n if (!nextVs) return false\n\n const nextSplat = createProgram(gl, nextVs, FRAG_SPLAT)\n const nextAdvect = createProgram(gl, nextVs, FRAG_ADVECT)\n const nextDivergence = createProgram(gl, nextVs, FRAG_DIVERGENCE)\n const nextPressure = createProgram(gl, nextVs, FRAG_PRESSURE)\n const nextGradient = createProgram(gl, nextVs, FRAG_GRADIENT)\n const nextDisplay = createProgram(gl, nextVs, FRAG_DISPLAY)\n if (\n !nextSplat ||\n !nextAdvect ||\n !nextDivergence ||\n !nextPressure ||\n !nextGradient ||\n !nextDisplay\n ) {\n return false\n }\n\n const nextBuf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, nextBuf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n\n const nextVelocity = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const nextDye = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const nextPressureFbo = createDoubleFBO(gl, SIM, SIM, gl.NEAREST)\n const nextDivergenceFbo = createFBO(gl, SIM, SIM, gl.NEAREST)\n if (!nextVelocity || !nextDye || !nextPressureFbo || !nextDivergenceFbo) {\n return false\n }\n\n const nextAtlas = buildAtlas(gl, options.charset)\n if (!nextAtlas) return false\n\n vs = nextVs\n splat = nextSplat\n advect = nextAdvect\n divergence = nextDivergence\n pressure = nextPressure\n gradient = nextGradient\n display = nextDisplay\n buf = nextBuf\n velocity = nextVelocity\n dye = nextDye\n pressureFbo = nextPressureFbo\n divergenceFbo = nextDivergenceFbo\n atlas = nextAtlas\n currentCharset = options.charset\n\n clearFbo(velocity.read, 0.5, 0.5, 0.5)\n clearFbo(velocity.write, 0.5, 0.5, 0.5)\n clearFbo(dye.read)\n clearFbo(dye.write)\n return true\n }\n\n if (!initGl()) return null\n\n let raf = 0\n let destroyed = false\n // Set while the WebGL context is lost; nothing may touch gl until restored.\n let lost = false\n let onScreen = true\n let last = performance.now()\n const start = last\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n }\n\n resize()\n // Resizing clears the canvas, so redraw even when the loop is idle.\n const ro = new ResizeObserver(() => {\n resize()\n wake()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const onPointer = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n mouse.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n mouse.dx = x - mouse.x\n mouse.dy = y - mouse.y\n mouse.x = x\n mouse.y = y\n mouse.moved = true\n }\n const onLeave = () => {\n mouse.inside = false\n }\n\n window.addEventListener(\"pointermove\", onPointer, { passive: true })\n const parentEl = canvas.parentElement\n parentEl?.addEventListener(\"pointerleave\", onLeave, { passive: true })\n\n const tick = (now: number) => {\n raf = 0\n if (destroyed || lost) return\n // Off screen or in a hidden tab: stop until an observer wakes the loop.\n if (!onScreen || document.visibilityState === \"hidden\") return\n reduce = mqReduce.matches\n const dt = Math.min(Math.max(0, (now - last) / 1000), 0.033)\n last = now\n const time = (now - start) / 1000\n const p = options\n\n if (p.charset !== currentCharset) {\n const next = buildAtlas(gl, p.charset)\n if (next) {\n gl.deleteTexture(atlas.tex)\n atlas = next\n currentCharset = p.charset\n }\n }\n\n const dark = resolveDark(p.theme)\n const ink = hexToRgb(p.color ?? (dark ? DARK.ink : LIGHT.ink))\n const paper = hexToRgb(\n p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n )\n const texel = [1 / SIM, 1 / SIM] as const\n const aspect = canvas.width / Math.max(canvas.height, 1)\n const brushR = 0.00012 + Math.max(0.05, Math.min(1, p.brush)) * 0.0011\n\n if (p.interactive && mouse.moved && mouse.inside && !reduce) {\n const speed = Math.hypot(mouse.dx, mouse.dy)\n const strength = p.force * (18 + speed * 120)\n\n // Velocity trail\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n mouse.dx * strength,\n mouse.dy * strength,\n 0\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), brushR)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n // Dye trail, denser with speed so fast moves write brighter glyphs\n const dyeAmt = Math.min(1.4, 0.45 + speed * 8) * p.force\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n dyeAmt,\n 0,\n 0\n )\n gl.uniform1f(\n gl.getUniformLocation(splat.program, \"u_radius\"),\n brushR * 1.15\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n\n mouse.moved = false\n mouse.dx = 0\n mouse.dy = 0\n }\n\n // Soft ambient swirl so the field never fully dies when idle\n if (p.animate && !reduce && !mouse.inside) {\n const ax = Math.sin(time * 0.55) * 0.22\n const ay = Math.cos(time * 0.42) * 0.22\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n 0.5 + Math.sin(time * 0.23) * 0.22,\n 0.5 + Math.cos(time * 0.19) * 0.18\n )\n gl.uniform3f(gl.getUniformLocation(splat.program, \"u_color\"), ax, ay, 0)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), 0.0018)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n }\n\n if (!reduce) {\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.18, p.dissipation * 2.5)\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(divergence.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(divergence.program, \"u_velocity\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(divergence.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(divergenceFbo)\n\n clearFbo(pressureFbo.read, 0.5, 0.5, 0.5)\n clearFbo(pressureFbo.write, 0.5, 0.5, 0.5)\n bindQuad(pressure.program)\n for (let i = 0; i < 14; i++) {\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, divergenceFbo.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_divergence\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(pressure.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(pressureFbo.write)\n pressureFbo.swap()\n }\n\n bindQuad(gradient.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_velocity\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(gradient.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.22, Math.max(0.02, p.dissipation))\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n }\n\n bindQuad(display.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_dye\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, atlas.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_atlas\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(display.program, \"u_resolution\"),\n canvas.width,\n canvas.height\n )\n const cell =\n Math.max(7, p.cellSize) * Math.min(window.devicePixelRatio || 1, 2)\n gl.uniform2f(gl.getUniformLocation(display.program, \"u_cell\"), cell, cell)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_charCount\"),\n atlas.count\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_ink\"),\n ink[0],\n ink[1],\n ink[2]\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_paper\"),\n paper[0],\n paper[1],\n paper[2]\n )\n gl.uniform1f(gl.getUniformLocation(display.program, \"u_time\"), time)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_animate\"),\n p.animate && !reduce ? 1 : 0\n )\n blit(null)\n\n // Reduced motion skips the simulation, so every frame would be the same;\n // hold this one until a resize, theme or option change wakes the loop.\n if (!reduce) raf = requestAnimationFrame(tick)\n }\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onTheme = () => wake()\n const mo = new MutationObserver(onTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", onTheme)\n\n // Without preventDefault() the browser never restores a lost context.\n const onContextLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n // Every GL object died with the old context, so rebuild them all and\n // restart the fluid empty. If that fails, stay quietly blank.\n const onContextRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n mouse.moved = false\n mouse.dx = 0\n mouse.dy = 0\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onContextLost)\n canvas.addEventListener(\"webglcontextrestored\", onContextRestored)\n\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", onTheme)\n window.removeEventListener(\"pointermove\", onPointer)\n parentEl?.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onContextLost)\n canvas.removeEventListener(\"webglcontextrestored\", onContextRestored)\n gl.deleteProgram(splat.program)\n gl.deleteProgram(advect.program)\n gl.deleteProgram(divergence.program)\n gl.deleteProgram(pressure.program)\n gl.deleteProgram(gradient.program)\n gl.deleteProgram(display.program)\n gl.deleteShader(vs)\n gl.deleteShader(splat.fs)\n gl.deleteShader(advect.fs)\n gl.deleteShader(divergence.fs)\n gl.deleteShader(pressure.fs)\n gl.deleteShader(gradient.fs)\n gl.deleteShader(display.fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(atlas.tex)\n for (const f of [\n velocity.read,\n velocity.write,\n dye.read,\n dye.write,\n pressureFbo.read,\n pressureFbo.write,\n divergenceFbo,\n ]) {\n gl.deleteTexture(f.tex)\n gl.deleteFramebuffer(f.fbo)\n }\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type AsciiFluidProps = AsciiFluidOptions & {\n className?: string\n}\n\n/**\n * ASCII fluid background: pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n */\nexport function AsciiFluid({\n className,\n charset = DEFAULT_CHARSET,\n cellSize = 12,\n color,\n backgroundColor,\n force = 1,\n dissipation = 0.05,\n brush = 0.55,\n animate = true,\n interactive = true,\n theme = \"auto\",\n}: AsciiFluidProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createAsciiFluid(canvas, {\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n }, [\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n ])\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n
    \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiFluidTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type AsciiFluidOptions = {\n /**\n * Brightness ramp (sparse → dense). Default is a full letter/symbol map.\n */\n charset?: string\n /** Glyph cell size in CSS pixels. Default `12` */\n cellSize?: number\n /** Ink color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Default follows theme. */\n backgroundColor?: string\n /** Mouse trail / fluid force. Default `1` */\n force?: number\n /** How quickly trails fade (higher = vanish sooner). Default `0.05` */\n dissipation?: number\n /** Trail brush size 0–1. Default `0.55` */\n brush?: number\n /** Soft ambient swirl when idle. Default `true` */\n animate?: boolean\n /** Follow pointer. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiFluidTheme\n}\n\nexport type AsciiFluidInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Sparse → dense brightness ramp (letters + symbols). */\nexport const DEFAULT_CHARSET =\n \" .'`^\\\",:;Il!i><~+_-?][}{1)(|\\\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$\"\n\nconst LIGHT = { ink: \"#18181b\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#e4e4e7\", paper: \"#09090b\" }\n\nconst VERT = `\nattribute vec2 a_position;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_position * 0.5 + 0.5;\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\n// Velocity / dye stored biased in RGBA8: enc(v) = v * 0.05 + 0.5\nconst FRAG_SPLAT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_target;\nuniform vec2 u_point;\nuniform vec3 u_color;\nuniform float u_radius;\nuniform float u_aspect;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 p = v_uv - u_point;\n p.x *= u_aspect;\n float d = exp(-dot(p, p) / max(u_radius, 0.0001));\n vec3 base = texture2D(u_target, v_uv).xyz;\n if (u_velocityField > 0.5) {\n vec2 next = dec(base.xy) + u_color.xy * d;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(base + u_color * d, 1.0);\n }\n}\n`\n\nconst FRAG_ADVECT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform sampler2D u_source;\nuniform vec2 u_texel;\nuniform float u_dt;\nuniform float u_dissipation;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vec2 coord = v_uv - u_dt * vel * u_texel * 110.0;\n vec4 src = texture2D(u_source, clamp(coord, 0.0, 1.0));\n if (u_velocityField > 0.5) {\n vec2 next = dec(src.xy) * u_dissipation;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(src.xyz * u_dissipation, 1.0);\n }\n}\n`\n\nconst FRAG_DIVERGENCE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\n\nvoid main() {\n float L = dec(texture2D(u_velocity, v_uv - vec2(u_texel.x, 0.0)).xy).x;\n float R = dec(texture2D(u_velocity, v_uv + vec2(u_texel.x, 0.0)).xy).x;\n float B = dec(texture2D(u_velocity, v_uv - vec2(0.0, u_texel.y)).xy).y;\n float T = dec(texture2D(u_velocity, v_uv + vec2(0.0, u_texel.y)).xy).y;\n float div = 0.5 * ((R - L) + (T - B));\n gl_FragColor = vec4(div * 0.05 + 0.5, 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_PRESSURE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_divergence;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nfloat enc1(float v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n float C = dec1(texture2D(u_divergence, v_uv).x);\n float p = (L + R + B + T - C) * 0.25;\n gl_FragColor = vec4(enc1(p), 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_GRADIENT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vel -= vec2(R - L, T - B) * 0.5;\n gl_FragColor = vec4(enc(vel), 0.5, 1.0);\n}\n`\n\nconst FRAG_DISPLAY = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_dye;\nuniform sampler2D u_atlas;\nuniform vec2 u_resolution;\nuniform vec2 u_cell;\nuniform float u_charCount;\nuniform vec3 u_ink;\nuniform vec3 u_paper;\nuniform float u_time;\nuniform float u_animate;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);\n}\n\nvoid main() {\n vec2 pixel = v_uv * u_resolution;\n vec2 cell = floor(pixel / u_cell);\n vec2 cellUv = (cell + 0.5) * u_cell / u_resolution;\n\n float dens = clamp(texture2D(u_dye, cellUv).x, 0.0, 1.0);\n\n // Soft neighborhood sample for a low-res glow halo under the glyphs\n vec2 texel = u_cell / u_resolution;\n float glow =\n dens * 0.40 +\n texture2D(u_dye, cellUv + vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv + vec2(0.0, texel.y)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2(0.0, texel.y)).x * 0.15;\n glow = clamp(glow, 0.0, 1.0);\n glow = pow(glow, 1.35);\n\n float lit = dens;\n if (u_animate > 0.5) {\n float flicker = hash21(cell + floor(u_time * 10.0)) - 0.5;\n lit = clamp(lit + flicker * 0.05, 0.0, 1.0);\n }\n\n float idx = min(floor(lit * (u_charCount - 0.001)), u_charCount - 1.0);\n vec2 local = fract(pixel / u_cell);\n float u0 = (idx + local.x) / u_charCount;\n float glyph = texture2D(u_atlas, vec2(u0, local.y)).r;\n\n float alpha = glyph * smoothstep(0.02, 0.12, dens);\n\n // Paper → soft ink wash → sharp ASCII on top\n float wash = glow * 0.22;\n vec3 col = mix(u_paper, u_ink, wash);\n col = mix(col, u_ink, clamp(alpha, 0.0, 1.0));\n gl_FragColor = vec4(col, 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.1, 0.1, 0.12]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiFluidTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nfunction createProgram(\n gl: WebGLRenderingContext,\n vs: WebGLShader,\n fragSource: string\n) {\n const fs = compile(gl, gl.FRAGMENT_SHADER, fragSource)\n if (!fs) return null\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(fs)\n return null\n }\n return { program, fs }\n}\n\ntype FBO = {\n tex: WebGLTexture\n fbo: WebGLFramebuffer\n w: number\n h: number\n}\n\nfunction createFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n): FBO | null {\n const tex = gl.createTexture()\n const fbo = gl.createFramebuffer()\n if (!tex || !fbo) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n w,\n h,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n null\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)\n gl.framebufferTexture2D(\n gl.FRAMEBUFFER,\n gl.COLOR_ATTACHMENT0,\n gl.TEXTURE_2D,\n tex,\n 0\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n return { tex, fbo, w, h }\n}\n\nfunction createDoubleFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n) {\n const a = createFBO(gl, w, h, filter)\n const b = createFBO(gl, w, h, filter)\n if (!a || !b) return null\n return {\n read: a,\n write: b,\n swap() {\n const t = this.read\n this.read = this.write\n this.write = t\n },\n }\n}\n\nfunction buildAtlas(\n gl: WebGLRenderingContext,\n charset: string\n): { tex: WebGLTexture; count: number } | null {\n const count = Math.max(charset.length, 1)\n const size = 64\n const atlasCanvas = document.createElement(\"canvas\")\n atlasCanvas.width = size * count\n atlasCanvas.height = size\n const ctx = atlasCanvas.getContext(\"2d\")\n if (!ctx) return null\n ctx.fillStyle = \"#000\"\n ctx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height)\n ctx.fillStyle = \"#fff\"\n ctx.font = `700 ${Math.floor(size * 0.72)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n for (let i = 0; i < count; i++) {\n const ch = charset[i] ?? \" \"\n if (ch === \" \") continue\n ctx.fillText(ch, size * (i + 0.5), size * 0.55)\n }\n\n const tex = gl.createTexture()\n if (!tex) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n atlasCanvas\n )\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n return { tex, count }\n}\n\n/**\n * ASCII fluid background: pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n *\n * The simulation stops while the canvas is off screen or the tab is hidden.\n * Under `prefers-reduced-motion` it holds a still frame and only redraws\n * when its size, theme or options change. It recovers from a lost WebGL\n * context.\n */\nexport function createAsciiFluid(\n canvas: HTMLCanvasElement,\n initial: AsciiFluidOptions = {}\n): AsciiFluidInstance | null {\n let options: Required<\n Pick<\n AsciiFluidOptions,\n | \"charset\"\n | \"cellSize\"\n | \"force\"\n | \"dissipation\"\n | \"brush\"\n | \"animate\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiFluidOptions = {\n charset: DEFAULT_CHARSET,\n cellSize: 12,\n force: 1,\n dissipation: 0.05,\n brush: 0.55,\n animate: true,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = {\n x: 0.5,\n y: 0.5,\n dx: 0,\n dy: 0,\n moved: false,\n inside: false,\n }\n let reduce = false\n let currentCharset = \"\"\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n preserveDrawingBuffer: false,\n })\n if (!gl) return null\n\n type Program = NonNullable>\n type DoubleFBO = NonNullable>\n\n // GL resources live in `let`s so a restored context can rebuild them.\n let vs: WebGLShader\n let splat: Program\n let advect: Program\n let divergence: Program\n let pressure: Program\n let gradient: Program\n let display: Program\n let buf: WebGLBuffer | null\n let velocity: DoubleFBO\n let dye: DoubleFBO\n let pressureFbo: DoubleFBO\n let divergenceFbo: FBO\n let atlas: { tex: WebGLTexture; count: number }\n\n const bindQuad = (program: WebGLProgram) => {\n gl.useProgram(program)\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n }\n\n const SIM = 180\n\n const blit = (target: FBO | null) => {\n if (target) {\n gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)\n gl.viewport(0, 0, target.w, target.h)\n } else {\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n }\n\n const clearFbo = (fbo: FBO, r = 0, g = 0, b = 0) => {\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.fbo)\n gl.viewport(0, 0, fbo.w, fbo.h)\n gl.clearColor(r, g, b, 1)\n gl.clear(gl.COLOR_BUFFER_BIT)\n }\n\n // Builds every GL resource with an empty fluid. Runs at startup and again\n // when a lost context is restored; returns false if anything fails.\n const initGl = () => {\n const nextVs = compile(gl, gl.VERTEX_SHADER, VERT)\n if (!nextVs) return false\n\n const nextSplat = createProgram(gl, nextVs, FRAG_SPLAT)\n const nextAdvect = createProgram(gl, nextVs, FRAG_ADVECT)\n const nextDivergence = createProgram(gl, nextVs, FRAG_DIVERGENCE)\n const nextPressure = createProgram(gl, nextVs, FRAG_PRESSURE)\n const nextGradient = createProgram(gl, nextVs, FRAG_GRADIENT)\n const nextDisplay = createProgram(gl, nextVs, FRAG_DISPLAY)\n if (\n !nextSplat ||\n !nextAdvect ||\n !nextDivergence ||\n !nextPressure ||\n !nextGradient ||\n !nextDisplay\n ) {\n return false\n }\n\n const nextBuf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, nextBuf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n\n const nextVelocity = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const nextDye = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const nextPressureFbo = createDoubleFBO(gl, SIM, SIM, gl.NEAREST)\n const nextDivergenceFbo = createFBO(gl, SIM, SIM, gl.NEAREST)\n if (!nextVelocity || !nextDye || !nextPressureFbo || !nextDivergenceFbo) {\n return false\n }\n\n const nextAtlas = buildAtlas(gl, options.charset)\n if (!nextAtlas) return false\n\n vs = nextVs\n splat = nextSplat\n advect = nextAdvect\n divergence = nextDivergence\n pressure = nextPressure\n gradient = nextGradient\n display = nextDisplay\n buf = nextBuf\n velocity = nextVelocity\n dye = nextDye\n pressureFbo = nextPressureFbo\n divergenceFbo = nextDivergenceFbo\n atlas = nextAtlas\n currentCharset = options.charset\n\n clearFbo(velocity.read, 0.5, 0.5, 0.5)\n clearFbo(velocity.write, 0.5, 0.5, 0.5)\n clearFbo(dye.read)\n clearFbo(dye.write)\n return true\n }\n\n if (!initGl()) return null\n\n let raf = 0\n let destroyed = false\n // Set while the WebGL context is lost; nothing may touch gl until restored.\n let lost = false\n let onScreen = true\n let last = performance.now()\n const start = last\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n /**\n * Draws now: resizing cleared the canvas, and waiting for the next frame\n * would show it blank for one. Zero dt, so time doesn't jump; the tick\n * reschedules itself if it should keep running.\n */\n const redraw = () => {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n /** Returns whether the backing size changed (which clears the canvas). */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n if (width === canvas.width && height === canvas.height) return false\n canvas.width = width\n canvas.height = height\n return true\n }\n\n resize()\n // Resizing clears the canvas, so redraw even when the loop is idle.\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n else wake()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const onPointer = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n mouse.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n mouse.dx = x - mouse.x\n mouse.dy = y - mouse.y\n mouse.x = x\n mouse.y = y\n mouse.moved = true\n }\n const onLeave = () => {\n mouse.inside = false\n }\n\n window.addEventListener(\"pointermove\", onPointer, { passive: true })\n const parentEl = canvas.parentElement\n parentEl?.addEventListener(\"pointerleave\", onLeave, { passive: true })\n\n const tick = (now: number) => {\n raf = 0\n if (destroyed || lost) return\n // Off screen or in a hidden tab: stop until an observer wakes the loop.\n if (!onScreen || document.visibilityState === \"hidden\") return\n reduce = mqReduce.matches\n const dt = Math.min(Math.max(0, (now - last) / 1000), 0.033)\n last = now\n const time = (now - start) / 1000\n const p = options\n\n if (p.charset !== currentCharset) {\n const next = buildAtlas(gl, p.charset)\n if (next) {\n gl.deleteTexture(atlas.tex)\n atlas = next\n currentCharset = p.charset\n }\n }\n\n const dark = resolveDark(p.theme)\n const ink = hexToRgb(p.color ?? (dark ? DARK.ink : LIGHT.ink))\n const paper = hexToRgb(\n p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n )\n const texel = [1 / SIM, 1 / SIM] as const\n const aspect = canvas.width / Math.max(canvas.height, 1)\n const brushR = 0.00012 + Math.max(0.05, Math.min(1, p.brush)) * 0.0011\n\n if (p.interactive && mouse.moved && mouse.inside && !reduce) {\n const speed = Math.hypot(mouse.dx, mouse.dy)\n const strength = p.force * (18 + speed * 120)\n\n // Velocity trail\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n mouse.dx * strength,\n mouse.dy * strength,\n 0\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), brushR)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n // Dye trail, denser with speed so fast moves write brighter glyphs\n const dyeAmt = Math.min(1.4, 0.45 + speed * 8) * p.force\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n dyeAmt,\n 0,\n 0\n )\n gl.uniform1f(\n gl.getUniformLocation(splat.program, \"u_radius\"),\n brushR * 1.15\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n\n mouse.moved = false\n mouse.dx = 0\n mouse.dy = 0\n }\n\n // Soft ambient swirl so the field never fully dies when idle\n if (p.animate && !reduce && !mouse.inside) {\n const ax = Math.sin(time * 0.55) * 0.22\n const ay = Math.cos(time * 0.42) * 0.22\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n 0.5 + Math.sin(time * 0.23) * 0.22,\n 0.5 + Math.cos(time * 0.19) * 0.18\n )\n gl.uniform3f(gl.getUniformLocation(splat.program, \"u_color\"), ax, ay, 0)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), 0.0018)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n }\n\n if (!reduce) {\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.18, p.dissipation * 2.5)\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(divergence.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(divergence.program, \"u_velocity\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(divergence.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(divergenceFbo)\n\n clearFbo(pressureFbo.read, 0.5, 0.5, 0.5)\n clearFbo(pressureFbo.write, 0.5, 0.5, 0.5)\n bindQuad(pressure.program)\n for (let i = 0; i < 14; i++) {\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, divergenceFbo.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_divergence\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(pressure.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(pressureFbo.write)\n pressureFbo.swap()\n }\n\n bindQuad(gradient.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_velocity\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(gradient.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.22, Math.max(0.02, p.dissipation))\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n }\n\n bindQuad(display.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_dye\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, atlas.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_atlas\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(display.program, \"u_resolution\"),\n canvas.width,\n canvas.height\n )\n const cell =\n Math.max(7, p.cellSize) * Math.min(window.devicePixelRatio || 1, 2)\n gl.uniform2f(gl.getUniformLocation(display.program, \"u_cell\"), cell, cell)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_charCount\"),\n atlas.count\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_ink\"),\n ink[0],\n ink[1],\n ink[2]\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_paper\"),\n paper[0],\n paper[1],\n paper[2]\n )\n gl.uniform1f(gl.getUniformLocation(display.program, \"u_time\"), time)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_animate\"),\n p.animate && !reduce ? 1 : 0\n )\n blit(null)\n\n // Reduced motion skips the simulation, so every frame would be the same;\n // hold this one until a resize, theme or option change wakes the loop.\n if (!reduce) raf = requestAnimationFrame(tick)\n }\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onTheme = () => wake()\n const mo = new MutationObserver(onTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", onTheme)\n\n // Without preventDefault() the browser never restores a lost context.\n const onContextLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n // Every GL object died with the old context, so rebuild them all and\n // restart the fluid empty. If that fails, stay quietly blank.\n const onContextRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n mouse.moved = false\n mouse.dx = 0\n mouse.dy = 0\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onContextLost)\n canvas.addEventListener(\"webglcontextrestored\", onContextRestored)\n\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", onTheme)\n window.removeEventListener(\"pointermove\", onPointer)\n parentEl?.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onContextLost)\n canvas.removeEventListener(\"webglcontextrestored\", onContextRestored)\n gl.deleteProgram(splat.program)\n gl.deleteProgram(advect.program)\n gl.deleteProgram(divergence.program)\n gl.deleteProgram(pressure.program)\n gl.deleteProgram(gradient.program)\n gl.deleteProgram(display.program)\n gl.deleteShader(vs)\n gl.deleteShader(splat.fs)\n gl.deleteShader(advect.fs)\n gl.deleteShader(divergence.fs)\n gl.deleteShader(pressure.fs)\n gl.deleteShader(gradient.fs)\n gl.deleteShader(display.fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(atlas.tex)\n for (const f of [\n velocity.read,\n velocity.write,\n dye.read,\n dye.write,\n pressureFbo.read,\n pressureFbo.write,\n divergenceFbo,\n ]) {\n gl.deleteTexture(f.tex)\n gl.deleteFramebuffer(f.fbo)\n }\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type AsciiFluidProps = AsciiFluidOptions & {\n className?: string\n}\n\n/**\n * ASCII fluid background: pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n */\nexport function AsciiFluid({\n className,\n charset = DEFAULT_CHARSET,\n cellSize = 12,\n color,\n backgroundColor,\n force = 1,\n dissipation = 0.05,\n brush = 0.55,\n animate = true,\n interactive = true,\n theme = \"auto\",\n}: AsciiFluidProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createAsciiFluid(canvas, {\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n }, [\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n ])\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n
    \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-fluid.tsx" } diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index b28ddc9..d8d953b 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index a5af569..965b1bf 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark: glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n options.text = clampAsciiLogoText(options.text)\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark: glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : clampAsciiLogoText(text))\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs for cells inside the shape. One is picked per cell. */\n charset?: string\n /**\n * Pool of light glyphs for cells on the edge of the shape, where the\n * source only partly covers the cell. Default `EDGE_CHARSET`\n */\n edgeCharset?: string\n /**\n * Coverage (0–1) a cell must reach to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color. Default follows theme. */\n color?: string\n /**\n * Stage fill. Default `\"transparent\"`: the parent paints the background,\n * so the logo sits on any surface in both themes.\n */\n backgroundColor?: string\n /**\n * Glyph font family. Default: the root element's computed font, so a\n * `font-mono` class on it is enough; falls back to the system mono stack.\n */\n font?: string\n /** Glyphs fly in from off stage when the logo (re)builds. Default `true` */\n assemble?: boolean\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. `auto` (the default) reads `html.dark` / `html.light`,\n * then `data-theme`, then `prefers-color-scheme`, and keeps up with\n * changes.\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n /** How much of the cell the source covers (0–1); drives ink weight. */\n weight: number\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n /** Row offset where the glyph lands in the `fallen` phase. */\n landing: number\n /** Radians; glyphs tumble while they fall and settle flat again. */\n spin: number\n spinSpeed: number\n /** Brief brightening after a glyph swap under the cursor (0–1). */\n flash: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\n/** Light marks for the rim of the shape. */\nexport const EDGE_CHARSET = \".:;-=+*~'`,\"\n\n/** Stock ink per theme; a set `color` replaces it. */\nexport const LIGHT_INK = \"#2b2c2f\"\nexport const DARK_INK = \"#cfcfca\"\n\n/** Sampler pixels per cell edge; coverage averages a 4x4 block. */\nconst SUPERSAMPLE = 4\n/** Below this coverage a cell takes a light edge glyph. */\nconst EDGE_WEIGHT = 0.5\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\nconst MONO_FALLBACK =\n 'ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace'\n\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: AsciiLogoTheme = \"auto\"): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\n/**\n * Calls `onChange` whenever the page theme may have changed (the `html`\n * class, `data-theme` or `style`, or the system preference). Returns the\n * unsubscribe.\n */\nexport function watchTheme(onChange: () => void): () => void {\n if (typeof document === \"undefined\") return () => {}\n const mo = new MutationObserver(onChange)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", onChange)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", onChange)\n }\n}\n\nfunction pickChar(pool: string, fallback: string) {\n const chars = pool.length > 0 ? pool : fallback\n return chars[Math.floor(Math.random() * chars.length)] ?? \"#\"\n}\n\nfunction charFor(weight: number, charset: string, edgeCharset: string) {\n if (weight < EDGE_WEIGHT) return pickChar(edgeCharset, EDGE_CHARSET)\n return pickChar(charset, DEFAULT_CHARSET)\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/** Glyphs start off stage, spread around the frame, and ease home. */\nfunction flingOffStage(cells: AsciiCell[], cols: number, rows: number) {\n for (const cell of cells) {\n const side = Math.random()\n if (side < 0.5) {\n // Above or below the frame, anywhere across.\n cell.offsetX = (Math.random() * 2 - 1) * cols * 0.6\n cell.offsetY =\n side < 0.25\n ? -(cell.row + Math.random() * rows)\n : rows + Math.random() * rows * 0.5\n } else {\n // Left or right of the frame.\n cell.offsetX =\n side < 0.75\n ? -(cell.col + Math.random() * cols)\n : cols + Math.random() * cols * 0.5\n cell.offsetY = (Math.random() * 2 - 1) * rows * 0.6\n }\n cell.spin = (Math.random() * 2 - 1) * Math.PI\n cell.spinSpeed = 0\n cell.fallSpeed = 0\n }\n}\n\n/**\n * Interactive ASCII wordmark: glyphs assemble on load, shove away from the\n * cursor, then click-cycle through scatter, gravity drop, and reassemble.\n * Transparent, so it sits on any surface in light and dark. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"edgeCharset\"\n | \"threshold\"\n | \"assemble\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n edgeCharset: EDGE_CHARSET,\n threshold: 0.2,\n assemble: true,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n options.text = clampAsciiLogoText(options.text)\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridCols = 0\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n // The assemble flight plays when the source changes, not on a resize.\n let lastSource: string | null = null\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n let dark = resolveDark(options.theme)\n let fontFamily = \"\"\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme)\n }\n const unwatchTheme = watchTheme(syncTheme)\n\n const readFont = () => {\n if (options.font) {\n fontFamily = options.font\n return\n }\n const computed = getComputedStyle(root).fontFamily\n fontFamily = computed && computed !== \"\" ? computed : MONO_FALLBACK\n }\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n /** Paints the source onto a sampler `SUPERSAMPLE` times finer than the grid. */\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n sw: number,\n sh: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, sw, sh)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = sw * cover\n const maxH = sh * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (sw - dw) / 2, (sh - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = sh * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = sw * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n const x = sw / 2\n const y = sh / 2 + fontSize * 0.04\n sampler.fillText(word, x, y)\n // Fatten the strokes by about a cell so thin counters still read.\n sampler.strokeStyle = \"#fff\"\n sampler.lineJoin = \"round\"\n sampler.lineWidth = SUPERSAMPLE * 0.9\n sampler.strokeText(word, x, y)\n }\n\n /** Averages each cell's block of sampler pixels into a coverage weight. */\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const pixels = data.data\n const sw = cols * SUPERSAMPLE\n const next: AsciiCell[] = []\n const block = SUPERSAMPLE * SUPERSAMPLE\n\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n let sum = 0\n for (let sy = 0; sy < SUPERSAMPLE; sy++) {\n const y = row * SUPERSAMPLE + sy\n for (let sx = 0; sx < SUPERSAMPLE; sx++) {\n const x = col * SUPERSAMPLE + sx\n const i = (y * sw + x) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n sum += (shouldInvert ? 1 - luma : luma) * a\n }\n }\n const weight = sum / block\n if (weight < p.threshold) continue\n next.push({\n col,\n row,\n char: charFor(weight, p.charset, p.edgeCharset),\n weight,\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n landing: 0,\n spin: 0,\n spinSpeed: 0,\n flash: 0,\n wait: 0,\n })\n }\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.edgeCharset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sw = cols * SUPERSAMPLE\n const sh = rows * SUPERSAMPLE\n const sampler = document.createElement(\"canvas\")\n sampler.width = sw\n sampler.height = sh\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, sw, sh, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, sw, sh)\n } catch {\n data = null\n }\n if (!data && image) {\n // A tainted cross-origin image blocks reads; fall back to the text.\n sampleSource(samplerCtx, sw, sh, null, { ...snapshot, src: undefined })\n try {\n data = samplerCtx.getImageData(0, 0, sw, sh)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridCols = cols\n gridRows = rows\n cursor.x = -999\n cursor.y = -999\n readFont()\n\n const source = `${snapshot.src ?? \"\"}|${snapshot.text}`\n const fresh = source !== lastSource\n lastSource = source\n\n if (fresh && p.assemble && p.interactive && !reduce && cells.length > 0) {\n flingOffStage(cells, cols, rows)\n staggerCells(cells, p.staggerFrames * 2)\n phase = \"logo\"\n setPhase(\"returning\")\n } else {\n setPhase(\"logo\")\n }\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n cell.spinSpeed = (Math.random() * 2 - 1) * 0.05\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n // Glyphs heap up column by column, lowest ones landing first, so the\n // drop ends in drifts rather than one flat line.\n const pile = new Array(Math.max(1, gridCols)).fill(0)\n const order = [...cells].sort(\n (a, b) => b.row + b.offsetY - (a.row + a.offsetY)\n )\n for (const cell of order) {\n const col = Math.min(\n gridCols - 1,\n Math.max(0, Math.round(cell.col + cell.offsetX))\n )\n const stack = pile[col]!++\n cell.landing = Math.max(-cell.row, gridRows - 1 - stack - cell.row)\n cell.fallSpeed = 0\n cell.spinSpeed = (Math.random() * 2 - 1) * 0.22\n }\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.flash > 0) cell.flash = Math.max(0, cell.flash - 0.06 * frames)\n\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n cell.spin = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n cell.spin += cell.spinSpeed * frames\n cell.spinSpeed *= Math.pow(0.97, frames)\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = cell.landing\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n cell.spin += cell.spinSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n // Each landing bleeds off most of the tumble; glyphs come to rest\n // on a flat side rather than balancing on a corner.\n cell.spinSpeed *= 0.35\n if (cell.fallSpeed === 0) {\n cell.spinSpeed = 0\n const rest = Math.round(cell.spin / (Math.PI / 2)) * (Math.PI / 2)\n cell.spin += (rest - cell.spin) * frameEase(0.3, frames)\n }\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n cell.spin += (0 - cell.spin) * frameEase(p.resetEase, frames)\n if (\n Math.abs(cell.offsetX) > 0.04 ||\n Math.abs(cell.offsetY) > 0.04 ||\n Math.abs(cell.spin) > 0.01\n ) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = charFor(cell.weight, p.charset, p.edgeCharset)\n cell.flash = 1\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) {\n for (const cell of cells) {\n cell.offsetX = 0\n cell.offsetY = 0\n cell.spin = 0\n }\n setPhase(\"logo\")\n }\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const ink = p.color ?? (dark ? DARK_INK : LIGHT_INK)\n const paper = p.backgroundColor ?? \"transparent\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ${fontFamily || MONO_FALLBACK}`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n // Full ink inside the shape, lighter toward its rim, and a brief\n // brightening on a swapped glyph.\n const base = 0.4 + 0.6 * Math.min(1, cell.weight / 0.85)\n ctx.globalAlpha = Math.min(1, base + cell.flash * 0.6)\n if (Math.abs(cell.spin) > 0.001) {\n ctx.save()\n ctx.translate(x, y)\n ctx.rotate(cell.spin)\n ctx.fillText(cell.char, 0, 0)\n ctx.restore()\n } else {\n ctx.fillText(cell.char, x, y)\n }\n }\n ctx.globalAlpha = 1\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n readFont()\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n // The observer also fires once right after `observe`; only a real size\n // change rebuilds, so it doesn't cut the first assemble flight short.\n const ro = new ResizeObserver(() => {\n if (root.clientWidth === lastW && root.clientHeight === lastH) return\n lastKey = \"\"\n void rebuild()\n // Resizing cleared the canvas; draw now, or it shows blank for a frame\n // until the next tick.\n if (running) draw()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n dark = resolveDark(options.theme)\n if (\"font\" in next) readFont()\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n unwatchTheme()\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark: glyphs assemble on load, shove away from the\n * cursor, then click-cycle through scatter, gravity drop, and reassemble.\n * Transparent, so it sits on any surface in light and dark. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n edgeCharset = EDGE_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n font,\n assemble = true,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n edgeCharset,\n threshold,\n invert,\n color,\n backgroundColor,\n font,\n assemble,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n edgeCharset,\n threshold,\n invert,\n color,\n backgroundColor,\n font,\n assemble,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n edgeCharset,\n threshold,\n invert,\n color,\n backgroundColor,\n font,\n assemble,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : clampAsciiLogoText(text))\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/public/r/dithered-404-svelte.json b/public/r/dithered-404-svelte.json index 4a1275b..5f55ccf 100644 --- a/public/r/dithered-404-svelte.json +++ b/public/r/dithered-404-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/dithered-404/dithered-404.svelte", - "content": "\n\n\n\n\n 404\n \n\n", + "content": "\n\n\n\n\n 404\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/dithered-404.svelte" } diff --git a/public/r/dithered-404.json b/public/r/dithered-404.json index 69bc512..8195111 100644 --- a/public/r/dithered-404.json +++ b/public/r/dithered-404.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/dithered-404/dithered-404.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Dithered404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Dithered404Options = {\n /** Glyph ink (hex). Default follows theme. */\n color?: string\n /** Dither cell size in CSS px. Default `4` */\n pixelSize?: number\n /** Fireball radius in CSS px. Default `28` */\n brush?: number\n /** Follow pointer / replace cursor. Default `true` */\n interactive?: boolean\n /** Ordered Bayer print. Default `true`. Off is a soft realistic fire. */\n dither?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Dithered404Theme\n /** Fires when the fireball cursor should hide the system pointer. */\n onHideCursor?: (hide: boolean) => void\n}\n\nexport type Dithered404Instance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n\nconst FIRE_EMBER = \"#A33A18\"\nconst FIRE_FLAME = \"#D4682A\"\nconst FIRE_HIGHLIGHT = \"#E8B45A\"\n\nconst SOLID = 0\nconst EMBER = 1\nconst SMOKE = 2\nconst REFORMING = 3\n\nconst PHASE_READY = 0\nconst PHASE_REFORMING = 1\n\nconst RESET_DELAY_MS = 900\nconst REFORM_SNAP_MS = 1800\nconst COLLAPSE_RATIO = 0.12\nconst MAX_EMBERS = 900\nconst MAX_EMBERS_FINE = 260\nconst MAX_SMOKE = 420\nconst MAX_SMOKE_FINE = 160\nconst CHAR_HEAT_GAIN = 0.2\nconst CHAR_COOL = 0.01\n\nconst BAYER8 = [\n [0, 32, 8, 40, 2, 34, 10, 42],\n [48, 16, 56, 24, 50, 18, 58, 26],\n [12, 44, 4, 36, 14, 46, 6, 38],\n [60, 28, 52, 20, 62, 30, 54, 22],\n [3, 35, 11, 43, 1, 33, 9, 41],\n [51, 19, 59, 27, 49, 17, 57, 25],\n [15, 47, 7, 39, 13, 45, 5, 37],\n [63, 31, 55, 23, 61, 29, 53, 21],\n] as const\n\ntype Particle = {\n x: number\n y: number\n homeX: number\n homeY: number\n vx: number\n vy: number\n size: number\n state: 0 | 1 | 2 | 3\n life: number\n rot: number\n vr: number\n gx: number\n gy: number\n fill: string\n alpha: number\n sleep: number\n heat: number\n}\n\ntype GlyphCell = {\n homeX: number\n homeY: number\n gx: number\n gy: number\n alpha: number\n}\n\nfunction bayerAt(gx: number, gy: number) {\n const x = ((gx % 8) + 8) % 8\n const y = ((gy % 8) + 8) % 8\n return BAYER8[y]![x]! / 64\n}\n\nfunction hash2(x: number, y: number) {\n const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123\n return s - Math.floor(s)\n}\n\nfunction valueNoise(x: number, y: number) {\n const ix = Math.floor(x)\n const iy = Math.floor(y)\n const fx = x - ix\n const fy = y - iy\n const a = hash2(ix, iy)\n const b = hash2(ix + 1, iy)\n const c = hash2(ix, iy + 1)\n const d = hash2(ix + 1, iy + 1)\n const ux = fx * fx * (3 - 2 * fx)\n const uy = fy * fy * (3 - 2 * fy)\n return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy\n}\n\nfunction fbm(x: number, y: number) {\n let v = 0\n let a = 0.5\n let f = 1\n for (let i = 0; i < 3; i++) {\n v += valueNoise(x * f, y * f) * a\n a *= 0.5\n f *= 2.05\n }\n return v\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: Dithered404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction cellKey(gx: number, gy: number) {\n return gx * 4096 + gy\n}\n\nfunction fireTint(field: number) {\n if (field > 0.72) return FIRE_HIGHLIGHT\n if (field > 0.4) return FIRE_FLAME\n return FIRE_EMBER\n}\n\nfunction hexRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [163, 58, 24]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n}\n\nfunction rgba(hex: string, a: number) {\n const [r, g, b] = hexRgb(hex)\n return `rgba(${r},${g},${b},${a})`\n}\n\nfunction scorchedInk(ink: string, heat: number): string {\n if (heat <= 0.02) return ink\n const [r, g, b] = hexRgb(ink)\n const h = heat > 1 ? 1 : heat\n const lum = (r + g + b) / 3\n let nr: number\n let ng: number\n let nb: number\n if (lum >= 130) {\n const k = 1 - h * 0.9\n nr = r * k\n ng = g * k * (1 - h * 0.12)\n nb = b * k * (1 - h * 0.22)\n if (h > 0.5) {\n const g2 = (h - 0.5) / 0.5\n nr = nr * (1 - g2 * 0.35) + 163 * g2 * 0.55\n ng = ng * (1 - g2 * 0.35) + 58 * g2 * 0.4\n nb = nb * (1 - g2 * 0.35) + 24 * g2 * 0.25\n }\n } else {\n nr = r * (1 - h * 0.22) + 52 * h\n ng = g * (1 - h * 0.38) + 18 * h\n nb = b * (1 - h * 0.52) + 8 * h\n if (h > 0.55) {\n const g2 = (h - 0.55) / 0.45\n nr = nr * (1 - g2 * 0.4) + 163 * g2 * 0.7\n ng = ng * (1 - g2 * 0.4) + 58 * g2 * 0.5\n nb = nb * (1 - g2 * 0.4) + 24 * g2 * 0.3\n }\n }\n return `rgb(${nr | 0},${ng | 0},${nb | 0})`\n}\n\nfunction rasterizeGlyph(\n cssW: number,\n cssH: number,\n pixelSize: number,\n dpr: number,\n dither: boolean\n): GlyphCell[] {\n const w = Math.max(1, Math.floor(cssW * dpr))\n const h = Math.max(1, Math.floor(cssH * dpr))\n const cell = Math.max(1, Math.round(pixelSize * dpr))\n const off = document.createElement(\"canvas\")\n off.width = w\n off.height = h\n const ctx = off.getContext(\"2d\", { willReadFrequently: true })\n if (!ctx) return []\n\n const family = getComputedStyle(document.body).fontFamily || \"ui-sans-serif\"\n const fontSize = Math.min(cssW * 0.54, cssH * 0.5) * dpr\n ctx.clearRect(0, 0, w, h)\n ctx.fillStyle = \"#fff\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = `800 ${fontSize}px ${family}`\n if (\"letterSpacing\" in ctx) {\n ;(\n ctx as CanvasRenderingContext2D & { letterSpacing: string }\n ).letterSpacing = `${-fontSize * 0.045}px`\n }\n ctx.fillText(\"404\", w / 2, h / 2)\n\n const data = ctx.getImageData(0, 0, w, h).data\n const cells: GlyphCell[] = []\n\n for (let y = 0; y < h; y += cell) {\n for (let x = 0; x < w; x += cell) {\n let cover = 0\n let samples = 0\n const step = Math.max(1, Math.floor(cell / 3))\n for (let sy = 0; sy < cell; sy += step) {\n for (let sx = 0; sx < cell; sx += step) {\n const px = x + sx\n const py = y + sy\n if (px >= w || py >= h) continue\n cover += data[(py * w + px) * 4 + 3]!\n samples++\n }\n }\n const alpha = samples > 0 ? cover / (samples * 255) : 0\n if (alpha < (dither ? 0.18 : 0.08)) continue\n const gx = Math.floor(x / cell)\n const gy = Math.floor(y / cell)\n if (dither && alpha < bayerAt(gx, gy) * 0.92 + 0.08) continue\n cells.push({\n homeX: x / dpr,\n homeY: y / dpr,\n gx,\n gy,\n alpha: dither ? 1 : Math.min(1, alpha * 1.15),\n })\n }\n }\n\n return cells\n}\n\nfunction spawnFromCells(\n cells: GlyphCell[],\n pixelSize: number,\n ink: string,\n cssH: number,\n assemble: boolean\n): Particle[] {\n return cells.map((cell) => {\n const fromBelow = assemble\n return {\n x: fromBelow ? cell.homeX + (Math.random() - 0.5) * 28 : cell.homeX,\n y: fromBelow ? cssH + 12 + Math.random() * 90 : cell.homeY,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: fromBelow ? -2.2 - Math.random() * 1.4 : 0,\n size: pixelSize,\n state: fromBelow ? REFORMING : SOLID,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n }\n })\n}\n\nfunction smokeFill(dark: boolean) {\n if (dark) {\n return Math.random() > 0.45 ? \"#A1A1AA\" : \"#71717A\"\n }\n return Math.random() > 0.45 ? \"#52525B\" : \"#3F3F46\"\n}\n\nfunction burnParticle(\n particles: Particle[],\n p: Particle,\n emberCount: { n: number },\n smokeCount: { n: number },\n pixelSize: number,\n maxEmbers: number,\n maxSmoke: number,\n dark: boolean\n) {\n p.state = EMBER\n p.life = 0.28 + Math.random() * (pixelSize <= 2 ? 0.28 : 0.45)\n p.size = Math.max(1, p.size * 0.55)\n p.vx = (Math.random() - 0.5) * 2.8\n p.vy = -1.2 - Math.random() * 2.4\n p.fill = fireTint(0.35 + Math.random() * 0.65)\n p.alpha = 1\n p.heat = 0\n emberCount.n++\n\n const extra =\n pixelSize <= 2\n ? Math.random() < 0.22\n ? 1\n : 0\n : 1 + Math.floor(Math.random() * 3)\n for (let i = 0; i < extra && emberCount.n < maxEmbers; i++) {\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size,\n y: p.y + (Math.random() - 0.5) * p.size,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 3.4,\n vy: -1.6 - Math.random() * 2.8,\n size: Math.max(1, p.size * (0.35 + Math.random() * 0.4)),\n state: EMBER,\n life: 0.22 + Math.random() * 0.4,\n rot: 0,\n vr: 0,\n gx: p.gx,\n gy: p.gy,\n fill: fireTint(0.25 + Math.random() * 0.75),\n alpha: 1,\n sleep: 0,\n heat: 0,\n })\n emberCount.n++\n }\n\n const puffs =\n pixelSize <= 2\n ? Math.random() < 0.55\n ? 1\n : 0\n : 1 + (Math.random() < 0.55 ? 1 : 0)\n for (let i = 0; i < puffs && smokeCount.n < maxSmoke; i++) {\n const puff = pixelSize * (1.6 + Math.random() * 1.8)\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size * 0.8,\n y: p.y + (Math.random() - 0.5) * p.size * 0.8,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 0.55,\n vy: -0.45 - Math.random() * 0.85,\n size: puff,\n state: SMOKE,\n life: 0.75 + Math.random() * 0.95,\n rot: 0,\n vr: (Math.random() - 0.5) * 0.04,\n gx: p.gx,\n gy: p.gy,\n fill: smokeFill(dark),\n alpha: 0.42,\n sleep: 0,\n heat: 0,\n })\n smokeCount.n++\n }\n}\n\nfunction fireFieldAt(\n x: number,\n y: number,\n mx: number,\n my: number,\n r: number,\n t: number\n) {\n const dx = (x - mx) / r\n const dy = (y - my) / r\n const px = dx\n const py = dy > 0 ? dy * 1.4 : dy * 0.72\n const dist = Math.hypot(px, py)\n if (dist > 1.12) return 0\n const n = fbm(x * 0.055 + t * 1.7, y * 0.06 - t * 2.6)\n return Math.max(0, (1 - dist) * (0.38 + 0.62 * n))\n}\n\nfunction drawFireball(\n ctx: CanvasRenderingContext2D,\n mx: number,\n my: number,\n t: number,\n pixelSize: number,\n brush: number,\n dither: boolean\n) {\n const r = brush\n const minX = mx - r\n const maxX = mx + r\n const minY = my - r * 1.35\n const maxY = my + r * 0.85\n\n if (!dither) {\n ctx.save()\n ctx.globalCompositeOperation = \"lighter\"\n const glow = ctx.createRadialGradient(\n mx,\n my - r * 0.22,\n r * 0.04,\n mx,\n my - r * 0.08,\n r * 1.05\n )\n glow.addColorStop(0, rgba(FIRE_HIGHLIGHT, 0.95))\n glow.addColorStop(0.28, rgba(FIRE_FLAME, 0.72))\n glow.addColorStop(0.62, rgba(FIRE_EMBER, 0.32))\n glow.addColorStop(1, rgba(FIRE_EMBER, 0))\n ctx.fillStyle = glow\n ctx.beginPath()\n ctx.ellipse(mx, my - r * 0.18, r * 0.78, r * 1.12, 0, 0, Math.PI * 2)\n ctx.fill()\n\n const cell = 2\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const field = fireFieldAt(x + 1, y + 1, mx, my, r, t)\n if (field < 0.08) continue\n ctx.globalAlpha = Math.min(0.9, field * 1.05)\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(x, y, cell + 0.5, cell + 0.5)\n }\n }\n ctx.restore()\n return\n }\n\n const cell = Math.max(3, pixelSize)\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const cx = Math.floor(x / cell) * cell\n const cy = Math.floor(y / cell) * cell\n const field = fireFieldAt(cx + cell * 0.5, cy + cell * 0.5, mx, my, r, t)\n if (field < 0.1) continue\n if (\n field <\n bayerAt(Math.floor(cx / cell), Math.floor(cy / cell)) * 0.9 + 0.06\n ) {\n continue\n }\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(cx, cy, cell, cell)\n }\n }\n}\n\n/**\n * Bayer-pixel 404 section: a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire.\n */\nexport function createDithered404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Dithered404Options = {}\n): Dithered404Instance | null {\n let options: Required<\n Pick<\n Dithered404Options,\n \"pixelSize\" | \"brush\" | \"interactive\" | \"dither\" | \"theme\"\n >\n > &\n Dithered404Options = {\n pixelSize: 4,\n brush: 28,\n interactive: true,\n dither: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true, desynchronized: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let hover = false\n const mouse = { x: 0, y: 0 }\n const targetMouse = { x: 0, y: 0 }\n\n let raf = 0\n let running = true\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cells: GlyphCell[] = []\n let particles: Particle[] = []\n let lastPixel = -1\n let lastDither: boolean | null = null\n let lastW = 0\n let lastH = 0\n let phase = PHASE_READY\n let resetAt = 0\n let reformAt = 0\n let lastNow = performance.now()\n const start = lastNow\n\n const inkOf = () => options.color ?? (dark ? DARK_COLOR : LIGHT_COLOR)\n\n const rebuild = (assemble: boolean) => {\n const px = Math.max(2, Math.round(options.pixelSize))\n const useDither = options.dither\n cells = rasterizeGlyph(cssW, cssH, px, dpr, useDither)\n particles = spawnFromCells(cells, px, inkOf(), cssH, assemble)\n phase = assemble ? PHASE_REFORMING : PHASE_READY\n resetAt = 0\n reformAt = assemble ? performance.now() : 0\n lastPixel = px\n lastDither = useDither\n }\n\n const startReform = (now: number, px: number, ink: string) => {\n const have = new Set()\n for (const p of particles) {\n if (p.state === EMBER || p.state === SMOKE) continue\n p.state = REFORMING\n p.size = px\n p.fill = ink\n p.vx = 0\n p.vy = 0\n p.vr = 0\n p.sleep = 0\n p.life = 1\n p.heat = 0\n have.add(cellKey(p.gx, p.gy))\n }\n for (const cell of cells) {\n if (have.has(cellKey(cell.gx, cell.gy))) continue\n particles.push({\n x: cell.homeX + (Math.random() - 0.5) * 22,\n y: cssH - px - Math.random() * 52,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: 0,\n size: px,\n state: REFORMING,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n })\n }\n phase = PHASE_REFORMING\n resetAt = 0\n reformAt = now\n }\n\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return\n cssW = w\n cssH = h\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.imageSmoothingEnabled = false\n if (w !== lastW || h !== lastH) {\n lastW = w\n lastH = h\n rebuild(false)\n }\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n ro.observe(wrap)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive || reduce) return\n const rect = wrap.getBoundingClientRect()\n targetMouse.x = e.clientX - rect.left\n targetMouse.y = e.clientY - rect.top\n if (!hover) {\n hover = true\n options.onHideCursor?.(true)\n }\n }\n const onLeave = () => {\n hover = false\n options.onHideCursor?.(false)\n }\n\n wrap.addEventListener(\"pointermove\", onMove, { passive: true })\n wrap.addEventListener(\"pointerenter\", onMove, { passive: true })\n wrap.addEventListener(\"pointerleave\", onLeave)\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(32, now - lastNow) / 16.67\n lastNow = now\n const t = (now - start) / 1000\n const { pixelSize, brush, interactive, dither } = options\n const px = Math.max(2, Math.round(pixelSize))\n const ink = inkOf()\n\n if ((px !== lastPixel || dither !== lastDither) && cssW > 0) {\n rebuild(false)\n }\n\n mouse.x += (targetMouse.x - mouse.x) * 0.28\n mouse.y += (targetMouse.y - mouse.y) * 0.28\n\n if (!reduce) {\n if (resetAt && now >= resetAt) {\n startReform(now, px, ink)\n }\n\n let solidCount = 0\n let emberCount = 0\n let smokeCount = 0\n let reformingCount = 0\n const emberBudget = { n: 0 }\n const smokeBudget = { n: 0 }\n const maxEmbers = px <= 2 ? MAX_EMBERS_FINE : MAX_EMBERS\n const maxSmoke = px <= 2 ? MAX_SMOKE_FINE : MAX_SMOKE\n\n const canBurn = interactive && hover && phase === PHASE_READY\n\n const scorchR = brush * 0.92\n const scorchR2 = scorchR * scorchR\n const burnR = brush * 0.62\n const burnR2 = burnR * burnR\n\n for (let i = particles.length - 1; i >= 0; i--) {\n const p = particles[i]!\n if (p.state === SOLID) {\n if (canBurn) {\n const dx = p.x + p.size * 0.5 - mouse.x\n const dy = p.y + p.size * 0.5 - mouse.y\n const d2 = dx * dx + dy * dy\n if (d2 < scorchR2) {\n const heat = 1 - Math.sqrt(d2) / scorchR\n p.heat = Math.min(1, p.heat + heat * CHAR_HEAT_GAIN * dt)\n if (\n p.heat > 0.58 &&\n d2 < burnR2 &&\n Math.random() < heat * 0.28 * dt\n ) {\n burnParticle(\n particles,\n p,\n emberBudget,\n smokeBudget,\n px,\n maxEmbers,\n maxSmoke,\n dark\n )\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n if (p.state === SOLID) {\n p.fill = p.heat > 0 ? scorchedInk(ink, p.heat) : ink\n }\n }\n\n if (p.state === EMBER) {\n p.vy -= 0.09 * dt\n p.vx *= 0.94\n p.vy *= 0.97\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.life -= (px <= 2 ? 0.04 : 0.028) * dt\n p.size = Math.max(0.6, p.size * (1 - 0.02 * dt))\n if (p.life <= 0) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n emberCount++\n continue\n }\n\n if (p.state === SMOKE) {\n p.vy -= 0.018 * dt\n p.vx += Math.sin((now + p.x * 12) * 0.002) * 0.012 * dt\n p.vx *= 0.985\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.size += 0.12 * dt\n p.life -= (px <= 2 ? 0.018 : 0.012) * dt\n p.alpha = Math.max(0, p.life * 0.5)\n if (p.life <= 0 || p.y + p.size < -40) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n smokeCount++\n continue\n }\n\n if (p.state === REFORMING) {\n p.x += (p.homeX - p.x) * 0.16 * dt\n p.y += (p.homeY - p.y) * 0.16 * dt\n p.rot += (0 - p.rot) * 0.22 * dt\n p.vx = 0\n p.vy = 0\n p.fill = ink\n p.heat = 0\n if (Math.hypot(p.homeX - p.x, p.homeY - p.y) < 0.7) {\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n solidCount++\n } else {\n reformingCount++\n }\n continue\n }\n\n if (p.state === SOLID) {\n solidCount++\n }\n }\n\n const initialCount = cells.length || 1\n if (\n phase === PHASE_READY &&\n cells.length > 0 &&\n solidCount / initialCount < COLLAPSE_RATIO &&\n resetAt === 0\n ) {\n resetAt = now + RESET_DELAY_MS\n }\n\n if (phase === PHASE_REFORMING) {\n if (reformingCount === 0) {\n phase = PHASE_READY\n } else if (reformAt && now - reformAt > REFORM_SNAP_MS) {\n for (const p of particles) {\n if (p.state !== REFORMING) continue\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n p.fill = ink\n }\n phase = PHASE_READY\n }\n }\n\n if (emberCount > maxEmbers) {\n let extra = emberCount - maxEmbers\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === EMBER) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n\n if (smokeCount > maxSmoke) {\n let extra = smokeCount - maxSmoke\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === SMOKE) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n } else {\n // No simulation under reduced motion, but the ink still follows the\n // theme and the color prop.\n for (const p of particles) if (p.state === SOLID) p.fill = ink\n }\n\n ctx.clearRect(0, 0, cssW, cssH)\n ctx.imageSmoothingEnabled = !dither\n\n for (const p of particles) {\n if (p.state === EMBER) {\n ctx.globalAlpha = Math.max(0, Math.min(1, p.life * 1.4))\n ctx.fillStyle = p.fill\n if (!dither) {\n const glow = Math.max(1.2, p.size * 0.55)\n ctx.beginPath()\n ctx.arc(p.x + p.size * 0.5, p.y + p.size * 0.5, glow, 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n if (p.state === SMOKE) {\n ctx.globalAlpha = Math.max(0, Math.min(0.45, p.alpha))\n ctx.fillStyle = p.fill\n const half = p.size * 0.5\n if (!dither) {\n ctx.beginPath()\n ctx.arc(p.x + half, p.y + half, Math.max(1.2, half), 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n ctx.fillStyle = p.fill\n ctx.globalAlpha = dither ? 1 : p.alpha\n const pad = dither ? 0 : 0.35\n if (p.state === REFORMING && Math.abs(p.rot) > 0.04) {\n const half = p.size * 0.5\n ctx.save()\n ctx.translate(p.x + half, p.y + half)\n ctx.rotate(p.rot)\n ctx.fillRect(\n -half - pad,\n -half - pad,\n p.size + pad * 2,\n p.size + pad * 2\n )\n ctx.restore()\n } else {\n ctx.fillRect(p.x - pad, p.y - pad, p.size + pad * 2, p.size + pad * 2)\n }\n }\n\n ctx.globalAlpha = 1\n if (interactive && hover && !reduce) {\n drawFireball(ctx, mouse.x, mouse.y, t, px, brush, dither)\n }\n\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"pointermove\", onMove)\n wrap.removeEventListener(\"pointerenter\", onMove)\n wrap.removeEventListener(\"pointerleave\", onLeave)\n options.onHideCursor?.(false)\n },\n }\n}\n\nexport type Dithered404Props = Dithered404Options & {\n className?: string\n}\n\n/**\n * Bayer-pixel 404 section: a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire.\n */\nexport function Dithered404({\n className,\n color,\n pixelSize = 4,\n brush = 28,\n interactive = true,\n dither = true,\n theme = \"auto\",\n}: Dithered404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hideCursor, setHideCursor] = useState(false)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createDithered404(wrap, canvas, {\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n }, [color, pixelSize, brush, interactive, dither, theme])\n\n return (\n \n 404\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Dithered404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Dithered404Options = {\n /** Glyph ink (hex). Default follows theme. */\n color?: string\n /** Dither cell size in CSS px. Default `4` */\n pixelSize?: number\n /** Fireball radius in CSS px. Default `28` */\n brush?: number\n /** Follow pointer / replace cursor. Default `true` */\n interactive?: boolean\n /** Ordered Bayer print. Default `true`. Off is a soft realistic fire. */\n dither?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Dithered404Theme\n /** Fires when the fireball cursor should hide the system pointer. */\n onHideCursor?: (hide: boolean) => void\n}\n\nexport type Dithered404Instance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n\nconst FIRE_EMBER = \"#A33A18\"\nconst FIRE_FLAME = \"#D4682A\"\nconst FIRE_HIGHLIGHT = \"#E8B45A\"\n\nconst SOLID = 0\nconst EMBER = 1\nconst SMOKE = 2\nconst REFORMING = 3\n\nconst PHASE_READY = 0\nconst PHASE_REFORMING = 1\n\nconst RESET_DELAY_MS = 900\nconst REFORM_SNAP_MS = 1800\nconst COLLAPSE_RATIO = 0.12\nconst MAX_EMBERS = 900\nconst MAX_EMBERS_FINE = 260\nconst MAX_SMOKE = 420\nconst MAX_SMOKE_FINE = 160\nconst CHAR_HEAT_GAIN = 0.2\nconst CHAR_COOL = 0.01\n\nconst BAYER8 = [\n [0, 32, 8, 40, 2, 34, 10, 42],\n [48, 16, 56, 24, 50, 18, 58, 26],\n [12, 44, 4, 36, 14, 46, 6, 38],\n [60, 28, 52, 20, 62, 30, 54, 22],\n [3, 35, 11, 43, 1, 33, 9, 41],\n [51, 19, 59, 27, 49, 17, 57, 25],\n [15, 47, 7, 39, 13, 45, 5, 37],\n [63, 31, 55, 23, 61, 29, 53, 21],\n] as const\n\ntype Particle = {\n x: number\n y: number\n homeX: number\n homeY: number\n vx: number\n vy: number\n size: number\n state: 0 | 1 | 2 | 3\n life: number\n rot: number\n vr: number\n gx: number\n gy: number\n fill: string\n alpha: number\n sleep: number\n heat: number\n}\n\ntype GlyphCell = {\n homeX: number\n homeY: number\n gx: number\n gy: number\n alpha: number\n}\n\nfunction bayerAt(gx: number, gy: number) {\n const x = ((gx % 8) + 8) % 8\n const y = ((gy % 8) + 8) % 8\n return BAYER8[y]![x]! / 64\n}\n\nfunction hash2(x: number, y: number) {\n const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123\n return s - Math.floor(s)\n}\n\nfunction valueNoise(x: number, y: number) {\n const ix = Math.floor(x)\n const iy = Math.floor(y)\n const fx = x - ix\n const fy = y - iy\n const a = hash2(ix, iy)\n const b = hash2(ix + 1, iy)\n const c = hash2(ix, iy + 1)\n const d = hash2(ix + 1, iy + 1)\n const ux = fx * fx * (3 - 2 * fx)\n const uy = fy * fy * (3 - 2 * fy)\n return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy\n}\n\nfunction fbm(x: number, y: number) {\n let v = 0\n let a = 0.5\n let f = 1\n for (let i = 0; i < 3; i++) {\n v += valueNoise(x * f, y * f) * a\n a *= 0.5\n f *= 2.05\n }\n return v\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: Dithered404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction cellKey(gx: number, gy: number) {\n return gx * 4096 + gy\n}\n\nfunction fireTint(field: number) {\n if (field > 0.72) return FIRE_HIGHLIGHT\n if (field > 0.4) return FIRE_FLAME\n return FIRE_EMBER\n}\n\nfunction hexRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [163, 58, 24]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n}\n\nfunction rgba(hex: string, a: number) {\n const [r, g, b] = hexRgb(hex)\n return `rgba(${r},${g},${b},${a})`\n}\n\nfunction scorchedInk(ink: string, heat: number): string {\n if (heat <= 0.02) return ink\n const [r, g, b] = hexRgb(ink)\n const h = heat > 1 ? 1 : heat\n const lum = (r + g + b) / 3\n let nr: number\n let ng: number\n let nb: number\n if (lum >= 130) {\n const k = 1 - h * 0.9\n nr = r * k\n ng = g * k * (1 - h * 0.12)\n nb = b * k * (1 - h * 0.22)\n if (h > 0.5) {\n const g2 = (h - 0.5) / 0.5\n nr = nr * (1 - g2 * 0.35) + 163 * g2 * 0.55\n ng = ng * (1 - g2 * 0.35) + 58 * g2 * 0.4\n nb = nb * (1 - g2 * 0.35) + 24 * g2 * 0.25\n }\n } else {\n nr = r * (1 - h * 0.22) + 52 * h\n ng = g * (1 - h * 0.38) + 18 * h\n nb = b * (1 - h * 0.52) + 8 * h\n if (h > 0.55) {\n const g2 = (h - 0.55) / 0.45\n nr = nr * (1 - g2 * 0.4) + 163 * g2 * 0.7\n ng = ng * (1 - g2 * 0.4) + 58 * g2 * 0.5\n nb = nb * (1 - g2 * 0.4) + 24 * g2 * 0.3\n }\n }\n return `rgb(${nr | 0},${ng | 0},${nb | 0})`\n}\n\nfunction rasterizeGlyph(\n cssW: number,\n cssH: number,\n pixelSize: number,\n dpr: number,\n dither: boolean\n): GlyphCell[] {\n const w = Math.max(1, Math.floor(cssW * dpr))\n const h = Math.max(1, Math.floor(cssH * dpr))\n const cell = Math.max(1, Math.round(pixelSize * dpr))\n const off = document.createElement(\"canvas\")\n off.width = w\n off.height = h\n const ctx = off.getContext(\"2d\", { willReadFrequently: true })\n if (!ctx) return []\n\n const family = getComputedStyle(document.body).fontFamily || \"ui-sans-serif\"\n const fontSize = Math.min(cssW * 0.54, cssH * 0.5) * dpr\n ctx.clearRect(0, 0, w, h)\n ctx.fillStyle = \"#fff\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = `800 ${fontSize}px ${family}`\n if (\"letterSpacing\" in ctx) {\n ;(\n ctx as CanvasRenderingContext2D & { letterSpacing: string }\n ).letterSpacing = `${-fontSize * 0.045}px`\n }\n ctx.fillText(\"404\", w / 2, h / 2)\n\n const data = ctx.getImageData(0, 0, w, h).data\n const cells: GlyphCell[] = []\n\n for (let y = 0; y < h; y += cell) {\n for (let x = 0; x < w; x += cell) {\n let cover = 0\n let samples = 0\n const step = Math.max(1, Math.floor(cell / 3))\n for (let sy = 0; sy < cell; sy += step) {\n for (let sx = 0; sx < cell; sx += step) {\n const px = x + sx\n const py = y + sy\n if (px >= w || py >= h) continue\n cover += data[(py * w + px) * 4 + 3]!\n samples++\n }\n }\n const alpha = samples > 0 ? cover / (samples * 255) : 0\n if (alpha < (dither ? 0.18 : 0.08)) continue\n const gx = Math.floor(x / cell)\n const gy = Math.floor(y / cell)\n if (dither && alpha < bayerAt(gx, gy) * 0.92 + 0.08) continue\n cells.push({\n homeX: x / dpr,\n homeY: y / dpr,\n gx,\n gy,\n alpha: dither ? 1 : Math.min(1, alpha * 1.15),\n })\n }\n }\n\n return cells\n}\n\nfunction spawnFromCells(\n cells: GlyphCell[],\n pixelSize: number,\n ink: string,\n cssH: number,\n assemble: boolean\n): Particle[] {\n return cells.map((cell) => {\n const fromBelow = assemble\n return {\n x: fromBelow ? cell.homeX + (Math.random() - 0.5) * 28 : cell.homeX,\n y: fromBelow ? cssH + 12 + Math.random() * 90 : cell.homeY,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: fromBelow ? -2.2 - Math.random() * 1.4 : 0,\n size: pixelSize,\n state: fromBelow ? REFORMING : SOLID,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n }\n })\n}\n\nfunction smokeFill(dark: boolean) {\n if (dark) {\n return Math.random() > 0.45 ? \"#A1A1AA\" : \"#71717A\"\n }\n return Math.random() > 0.45 ? \"#52525B\" : \"#3F3F46\"\n}\n\nfunction burnParticle(\n particles: Particle[],\n p: Particle,\n emberCount: { n: number },\n smokeCount: { n: number },\n pixelSize: number,\n maxEmbers: number,\n maxSmoke: number,\n dark: boolean\n) {\n p.state = EMBER\n p.life = 0.28 + Math.random() * (pixelSize <= 2 ? 0.28 : 0.45)\n p.size = Math.max(1, p.size * 0.55)\n p.vx = (Math.random() - 0.5) * 2.8\n p.vy = -1.2 - Math.random() * 2.4\n p.fill = fireTint(0.35 + Math.random() * 0.65)\n p.alpha = 1\n p.heat = 0\n emberCount.n++\n\n const extra =\n pixelSize <= 2\n ? Math.random() < 0.22\n ? 1\n : 0\n : 1 + Math.floor(Math.random() * 3)\n for (let i = 0; i < extra && emberCount.n < maxEmbers; i++) {\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size,\n y: p.y + (Math.random() - 0.5) * p.size,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 3.4,\n vy: -1.6 - Math.random() * 2.8,\n size: Math.max(1, p.size * (0.35 + Math.random() * 0.4)),\n state: EMBER,\n life: 0.22 + Math.random() * 0.4,\n rot: 0,\n vr: 0,\n gx: p.gx,\n gy: p.gy,\n fill: fireTint(0.25 + Math.random() * 0.75),\n alpha: 1,\n sleep: 0,\n heat: 0,\n })\n emberCount.n++\n }\n\n const puffs =\n pixelSize <= 2\n ? Math.random() < 0.55\n ? 1\n : 0\n : 1 + (Math.random() < 0.55 ? 1 : 0)\n for (let i = 0; i < puffs && smokeCount.n < maxSmoke; i++) {\n const puff = pixelSize * (1.6 + Math.random() * 1.8)\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size * 0.8,\n y: p.y + (Math.random() - 0.5) * p.size * 0.8,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 0.55,\n vy: -0.45 - Math.random() * 0.85,\n size: puff,\n state: SMOKE,\n life: 0.75 + Math.random() * 0.95,\n rot: 0,\n vr: (Math.random() - 0.5) * 0.04,\n gx: p.gx,\n gy: p.gy,\n fill: smokeFill(dark),\n alpha: 0.42,\n sleep: 0,\n heat: 0,\n })\n smokeCount.n++\n }\n}\n\nfunction fireFieldAt(\n x: number,\n y: number,\n mx: number,\n my: number,\n r: number,\n t: number\n) {\n const dx = (x - mx) / r\n const dy = (y - my) / r\n const px = dx\n const py = dy > 0 ? dy * 1.4 : dy * 0.72\n const dist = Math.hypot(px, py)\n if (dist > 1.12) return 0\n const n = fbm(x * 0.055 + t * 1.7, y * 0.06 - t * 2.6)\n return Math.max(0, (1 - dist) * (0.38 + 0.62 * n))\n}\n\nfunction drawFireball(\n ctx: CanvasRenderingContext2D,\n mx: number,\n my: number,\n t: number,\n pixelSize: number,\n brush: number,\n dither: boolean\n) {\n const r = brush\n const minX = mx - r\n const maxX = mx + r\n const minY = my - r * 1.35\n const maxY = my + r * 0.85\n\n if (!dither) {\n ctx.save()\n ctx.globalCompositeOperation = \"lighter\"\n const glow = ctx.createRadialGradient(\n mx,\n my - r * 0.22,\n r * 0.04,\n mx,\n my - r * 0.08,\n r * 1.05\n )\n glow.addColorStop(0, rgba(FIRE_HIGHLIGHT, 0.95))\n glow.addColorStop(0.28, rgba(FIRE_FLAME, 0.72))\n glow.addColorStop(0.62, rgba(FIRE_EMBER, 0.32))\n glow.addColorStop(1, rgba(FIRE_EMBER, 0))\n ctx.fillStyle = glow\n ctx.beginPath()\n ctx.ellipse(mx, my - r * 0.18, r * 0.78, r * 1.12, 0, 0, Math.PI * 2)\n ctx.fill()\n\n const cell = 2\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const field = fireFieldAt(x + 1, y + 1, mx, my, r, t)\n if (field < 0.08) continue\n ctx.globalAlpha = Math.min(0.9, field * 1.05)\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(x, y, cell + 0.5, cell + 0.5)\n }\n }\n ctx.restore()\n return\n }\n\n const cell = Math.max(3, pixelSize)\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const cx = Math.floor(x / cell) * cell\n const cy = Math.floor(y / cell) * cell\n const field = fireFieldAt(cx + cell * 0.5, cy + cell * 0.5, mx, my, r, t)\n if (field < 0.1) continue\n if (\n field <\n bayerAt(Math.floor(cx / cell), Math.floor(cy / cell)) * 0.9 + 0.06\n ) {\n continue\n }\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(cx, cy, cell, cell)\n }\n }\n}\n\n/**\n * Bayer-pixel 404 section: a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire.\n */\nexport function createDithered404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Dithered404Options = {}\n): Dithered404Instance | null {\n let options: Required<\n Pick<\n Dithered404Options,\n \"pixelSize\" | \"brush\" | \"interactive\" | \"dither\" | \"theme\"\n >\n > &\n Dithered404Options = {\n pixelSize: 4,\n brush: 28,\n interactive: true,\n dither: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true, desynchronized: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let hover = false\n const mouse = { x: 0, y: 0 }\n const targetMouse = { x: 0, y: 0 }\n\n let raf = 0\n let running = true\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cells: GlyphCell[] = []\n let particles: Particle[] = []\n let lastPixel = -1\n let lastDither: boolean | null = null\n let lastW = 0\n let lastH = 0\n let phase = PHASE_READY\n let resetAt = 0\n let reformAt = 0\n let lastNow = performance.now()\n const start = lastNow\n\n const inkOf = () => options.color ?? (dark ? DARK_COLOR : LIGHT_COLOR)\n\n const rebuild = (assemble: boolean) => {\n const px = Math.max(2, Math.round(options.pixelSize))\n const useDither = options.dither\n cells = rasterizeGlyph(cssW, cssH, px, dpr, useDither)\n particles = spawnFromCells(cells, px, inkOf(), cssH, assemble)\n phase = assemble ? PHASE_REFORMING : PHASE_READY\n resetAt = 0\n reformAt = assemble ? performance.now() : 0\n lastPixel = px\n lastDither = useDither\n }\n\n const startReform = (now: number, px: number, ink: string) => {\n const have = new Set()\n for (const p of particles) {\n if (p.state === EMBER || p.state === SMOKE) continue\n p.state = REFORMING\n p.size = px\n p.fill = ink\n p.vx = 0\n p.vy = 0\n p.vr = 0\n p.sleep = 0\n p.life = 1\n p.heat = 0\n have.add(cellKey(p.gx, p.gy))\n }\n for (const cell of cells) {\n if (have.has(cellKey(cell.gx, cell.gy))) continue\n particles.push({\n x: cell.homeX + (Math.random() - 0.5) * 22,\n y: cssH - px - Math.random() * 52,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: 0,\n size: px,\n state: REFORMING,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n })\n }\n phase = PHASE_REFORMING\n resetAt = 0\n reformAt = now\n }\n\n /** Returns whether the backing size changed (which clears the canvas). */\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return false\n cssW = w\n cssH = h\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n const changed = width !== canvas.width || height !== canvas.height\n if (changed) {\n canvas.width = width\n canvas.height = height\n }\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.imageSmoothingEnabled = false\n if (w !== lastW || h !== lastH) {\n lastW = w\n lastH = h\n rebuild(false)\n }\n return changed\n }\n\n resize()\n // Resizing cleared the canvas; draw now (`tick` is declared below, and the\n // observer only fires after setup), or it shows blank for a frame.\n const ro = new ResizeObserver(() => {\n if (!resize() || !running) return\n cancelAnimationFrame(raf)\n lastNow = performance.now()\n tick(lastNow)\n })\n ro.observe(wrap)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive || reduce) return\n const rect = wrap.getBoundingClientRect()\n targetMouse.x = e.clientX - rect.left\n targetMouse.y = e.clientY - rect.top\n if (!hover) {\n hover = true\n options.onHideCursor?.(true)\n }\n }\n const onLeave = () => {\n hover = false\n options.onHideCursor?.(false)\n }\n\n wrap.addEventListener(\"pointermove\", onMove, { passive: true })\n wrap.addEventListener(\"pointerenter\", onMove, { passive: true })\n wrap.addEventListener(\"pointerleave\", onLeave)\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(32, now - lastNow) / 16.67\n lastNow = now\n const t = (now - start) / 1000\n const { pixelSize, brush, interactive, dither } = options\n const px = Math.max(2, Math.round(pixelSize))\n const ink = inkOf()\n\n if ((px !== lastPixel || dither !== lastDither) && cssW > 0) {\n rebuild(false)\n }\n\n mouse.x += (targetMouse.x - mouse.x) * 0.28\n mouse.y += (targetMouse.y - mouse.y) * 0.28\n\n if (!reduce) {\n if (resetAt && now >= resetAt) {\n startReform(now, px, ink)\n }\n\n let solidCount = 0\n let emberCount = 0\n let smokeCount = 0\n let reformingCount = 0\n const emberBudget = { n: 0 }\n const smokeBudget = { n: 0 }\n const maxEmbers = px <= 2 ? MAX_EMBERS_FINE : MAX_EMBERS\n const maxSmoke = px <= 2 ? MAX_SMOKE_FINE : MAX_SMOKE\n\n const canBurn = interactive && hover && phase === PHASE_READY\n\n const scorchR = brush * 0.92\n const scorchR2 = scorchR * scorchR\n const burnR = brush * 0.62\n const burnR2 = burnR * burnR\n\n for (let i = particles.length - 1; i >= 0; i--) {\n const p = particles[i]!\n if (p.state === SOLID) {\n if (canBurn) {\n const dx = p.x + p.size * 0.5 - mouse.x\n const dy = p.y + p.size * 0.5 - mouse.y\n const d2 = dx * dx + dy * dy\n if (d2 < scorchR2) {\n const heat = 1 - Math.sqrt(d2) / scorchR\n p.heat = Math.min(1, p.heat + heat * CHAR_HEAT_GAIN * dt)\n if (\n p.heat > 0.58 &&\n d2 < burnR2 &&\n Math.random() < heat * 0.28 * dt\n ) {\n burnParticle(\n particles,\n p,\n emberBudget,\n smokeBudget,\n px,\n maxEmbers,\n maxSmoke,\n dark\n )\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n if (p.state === SOLID) {\n p.fill = p.heat > 0 ? scorchedInk(ink, p.heat) : ink\n }\n }\n\n if (p.state === EMBER) {\n p.vy -= 0.09 * dt\n p.vx *= 0.94\n p.vy *= 0.97\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.life -= (px <= 2 ? 0.04 : 0.028) * dt\n p.size = Math.max(0.6, p.size * (1 - 0.02 * dt))\n if (p.life <= 0) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n emberCount++\n continue\n }\n\n if (p.state === SMOKE) {\n p.vy -= 0.018 * dt\n p.vx += Math.sin((now + p.x * 12) * 0.002) * 0.012 * dt\n p.vx *= 0.985\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.size += 0.12 * dt\n p.life -= (px <= 2 ? 0.018 : 0.012) * dt\n p.alpha = Math.max(0, p.life * 0.5)\n if (p.life <= 0 || p.y + p.size < -40) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n smokeCount++\n continue\n }\n\n if (p.state === REFORMING) {\n p.x += (p.homeX - p.x) * 0.16 * dt\n p.y += (p.homeY - p.y) * 0.16 * dt\n p.rot += (0 - p.rot) * 0.22 * dt\n p.vx = 0\n p.vy = 0\n p.fill = ink\n p.heat = 0\n if (Math.hypot(p.homeX - p.x, p.homeY - p.y) < 0.7) {\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n solidCount++\n } else {\n reformingCount++\n }\n continue\n }\n\n if (p.state === SOLID) {\n solidCount++\n }\n }\n\n const initialCount = cells.length || 1\n if (\n phase === PHASE_READY &&\n cells.length > 0 &&\n solidCount / initialCount < COLLAPSE_RATIO &&\n resetAt === 0\n ) {\n resetAt = now + RESET_DELAY_MS\n }\n\n if (phase === PHASE_REFORMING) {\n if (reformingCount === 0) {\n phase = PHASE_READY\n } else if (reformAt && now - reformAt > REFORM_SNAP_MS) {\n for (const p of particles) {\n if (p.state !== REFORMING) continue\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n p.fill = ink\n }\n phase = PHASE_READY\n }\n }\n\n if (emberCount > maxEmbers) {\n let extra = emberCount - maxEmbers\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === EMBER) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n\n if (smokeCount > maxSmoke) {\n let extra = smokeCount - maxSmoke\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === SMOKE) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n } else {\n // No simulation under reduced motion, but the ink still follows the\n // theme and the color prop.\n for (const p of particles) if (p.state === SOLID) p.fill = ink\n }\n\n ctx.clearRect(0, 0, cssW, cssH)\n ctx.imageSmoothingEnabled = !dither\n\n for (const p of particles) {\n if (p.state === EMBER) {\n ctx.globalAlpha = Math.max(0, Math.min(1, p.life * 1.4))\n ctx.fillStyle = p.fill\n if (!dither) {\n const glow = Math.max(1.2, p.size * 0.55)\n ctx.beginPath()\n ctx.arc(p.x + p.size * 0.5, p.y + p.size * 0.5, glow, 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n if (p.state === SMOKE) {\n ctx.globalAlpha = Math.max(0, Math.min(0.45, p.alpha))\n ctx.fillStyle = p.fill\n const half = p.size * 0.5\n if (!dither) {\n ctx.beginPath()\n ctx.arc(p.x + half, p.y + half, Math.max(1.2, half), 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n ctx.fillStyle = p.fill\n ctx.globalAlpha = dither ? 1 : p.alpha\n const pad = dither ? 0 : 0.35\n if (p.state === REFORMING && Math.abs(p.rot) > 0.04) {\n const half = p.size * 0.5\n ctx.save()\n ctx.translate(p.x + half, p.y + half)\n ctx.rotate(p.rot)\n ctx.fillRect(\n -half - pad,\n -half - pad,\n p.size + pad * 2,\n p.size + pad * 2\n )\n ctx.restore()\n } else {\n ctx.fillRect(p.x - pad, p.y - pad, p.size + pad * 2, p.size + pad * 2)\n }\n }\n\n ctx.globalAlpha = 1\n if (interactive && hover && !reduce) {\n drawFireball(ctx, mouse.x, mouse.y, t, px, brush, dither)\n }\n\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"pointermove\", onMove)\n wrap.removeEventListener(\"pointerenter\", onMove)\n wrap.removeEventListener(\"pointerleave\", onLeave)\n options.onHideCursor?.(false)\n },\n }\n}\n\nexport type Dithered404Props = Dithered404Options & {\n className?: string\n}\n\n/**\n * Bayer-pixel 404 section: a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire.\n */\nexport function Dithered404({\n className,\n color,\n pixelSize = 4,\n brush = 28,\n interactive = true,\n dither = true,\n theme = \"auto\",\n}: Dithered404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hideCursor, setHideCursor] = useState(false)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createDithered404(wrap, canvas, {\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n }, [color, pixelSize, brush, interactive, dither, theme])\n\n return (\n \n 404\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/dithered-404.tsx" } diff --git a/public/r/folio-svelte.json b/public/r/folio-svelte.json index 6fa7b4e..7effe99 100644 --- a/public/r/folio-svelte.json +++ b/public/r/folio-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/folio/folio.svelte", - "content": "\n\n\n\n{#if !windowScroll}\n \n \n {@render children?.()}\n \n \n{/if}\n", + "content": "\n\n\n\n{#if !windowScroll}\n \n \n {@render children?.()}\n \n \n{/if}\n", "type": "registry:file", "target": "src/lib/components/ui/folio.svelte" } diff --git a/public/r/folio.json b/public/r/folio.json index ae45b0e..a0a587e 100644 --- a/public/r/folio.json +++ b/public/r/folio.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/folio/folio.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const FOLIO_PLAY = \"folio:play\"\nexport const FOLIO_IDLE_MS = 200\n\nexport type FolioPlayDetail = {\n /** Only instances whose `demoId` matches will play. */\n target?: string\n /** Nested scroller to drive. Omit for `window`. */\n scrollRoot?: HTMLElement | null\n /** How long to hold the lean before springing back, in ms. Default `720`. */\n holdMs?: number\n}\n\nexport type FolioRuntimeOptions = {\n /** Peak blur in px at full tilt. Default `4`. */\n blur?: number\n /** CSS perspective distance in px. Default `1000`. Floor `1000`. */\n perspective?: number\n /**\n * How long the lean takes to come back after the bottom, in ms.\n * Default `520`.\n */\n returnMs?: number\n}\n\nexport type FolioInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nconst TILT_PEAK = 16\nconst DEFAULT_BLUR = 4\nconst DEFAULT_PERSPECTIVE = 1000\nconst MIN_PERSPECTIVE = 1000\nconst DEFAULT_RETURN_MS = 520\nconst VEL_REF = 2.4\nconst WHEEL_REF = 110\n/**\n * Wheel speed, in px/ms, that leans all the way. Trackpads send a stream\n * of small deltas, so a lean read per event barely moved; speed reads a\n * fast swipe and a fast mouse flick the same.\n */\nconst WHEEL_VEL_REF = 1.1\n/** A wheel gap longer than this starts a fresh gesture. */\nconst WHEEL_GESTURE_GAP_MS = 150\nconst WHEEL_SCROLL_LOCK_MS = 64\nconst END_SLACK_PX = 8\nconst SPRING_IN = { stiffness: 72, damping: 22 }\nconst SPRING_OUT = { stiffness: 42, damping: 18 }\n\ntype Spring = {\n set: (value: number) => void\n get: () => number\n destroy: () => void\n}\n\nfunction createSpring(onChange: (value: number) => void): Spring {\n let current = 0\n let target = 0\n let velocity = 0\n let raf = 0\n let lastTime = 0\n let stiffness = SPRING_IN.stiffness\n let damping = SPRING_IN.damping\n\n function integrate(dt: number) {\n const accel = -stiffness * (current - target) - damping * velocity\n velocity += accel * dt\n current += velocity * dt\n }\n\n function tick(now: number) {\n if (!lastTime) lastTime = now\n let remaining = Math.min((now - lastTime) / 1000, 0.048)\n lastTime = now\n const step = 1 / 60\n while (remaining > 0) {\n integrate(Math.min(step, remaining))\n remaining -= step\n }\n const settled =\n Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03\n if (settled) {\n current = target\n velocity = 0\n raf = 0\n lastTime = 0\n onChange(current)\n return\n }\n onChange(current)\n raf = requestAnimationFrame(tick)\n }\n\n function start() {\n if (!raf) {\n lastTime = 0\n raf = requestAnimationFrame(tick)\n }\n }\n\n return {\n set(value) {\n target = value\n if (value === 0) {\n stiffness = SPRING_OUT.stiffness\n damping = SPRING_OUT.damping\n } else {\n stiffness = SPRING_IN.stiffness\n damping = SPRING_IN.damping\n }\n if (Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03) {\n current = target\n velocity = 0\n onChange(current)\n return\n }\n start()\n },\n get() {\n return current\n },\n destroy() {\n if (raf) cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\nfunction isWindow(scroller: HTMLElement | Window): scroller is Window {\n return scroller === window\n}\n\nfunction readScrollTop(scroller: HTMLElement | Window) {\n return isWindow(scroller)\n ? window.scrollY || document.documentElement.scrollTop\n : scroller.scrollTop\n}\n\nfunction maxScroll(scroller: HTMLElement | Window) {\n if (isWindow(scroller)) {\n return Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n }\n return Math.max(0, scroller.scrollHeight - scroller.clientHeight)\n}\n\nfunction clamp01(n: number) {\n return Math.max(0, Math.min(1, n))\n}\n\nfunction clampSigned(n: number) {\n return Math.max(-1, Math.min(1, n))\n}\n\nfunction remainingScroll(scroller: HTMLElement | Window) {\n return Math.max(0, maxScroll(scroller) - readScrollTop(scroller))\n}\n\n/** `1` in the body of the page, `0` at the bottom so the down-lean dies. */\nfunction endFade(scroller: HTMLElement | Window) {\n const left = remainingScroll(scroller)\n if (left <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((left - END_SLACK_PX) / zone)\n}\n\n/** `1` in the body of the page, `0` at the top so the up-lean dies. */\nfunction startFade(scroller: HTMLElement | Window) {\n const top = readScrollTop(scroller)\n if (top <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((top - END_SLACK_PX) / zone)\n}\n\n/** Map scroll delta to a signed -1…1 lean. Positive = scrolling down. */\nexport function leanFromDelta(deltaPx: number, ref = WHEEL_REF) {\n return clampSigned(deltaPx / ref)\n}\n\n/** Slow travel sits near 5°, a flick reaches 16°. */\nfunction tiltAngle(impulse: number) {\n const mag = Math.min(1, Math.abs(impulse))\n const deg = mag * mag * (TILT_PEAK - 5) + mag * 5\n return deg * Math.sign(impulse || 1)\n}\n\nfunction hingeY(\n scroller: HTMLElement | Window,\n plane: HTMLElement,\n tilt: number\n) {\n const vh = viewHeightOf(scroller)\n const localViewTop = readScrollTop(scroller) - plane.offsetTop\n if (Math.abs(tilt) < 0.35) return localViewTop + vh * 0.5\n return tilt > 0 ? localViewTop + vh : localViewTop\n}\n\nexport function applyFolioFrame(\n plane: HTMLElement,\n tilt: number,\n options: FolioRuntimeOptions = {},\n reducedMotion = false,\n originY?: number\n) {\n const blur = Math.max(0, options.blur ?? DEFAULT_BLUR)\n const perspective = Math.max(\n MIN_PERSPECTIVE,\n options.perspective ?? DEFAULT_PERSPECTIVE\n )\n const angle = reducedMotion ? 0 : tilt\n const amount = Math.min(1, Math.abs(angle) / TILT_PEAK)\n const blurPx = reducedMotion ? 0 : amount * blur\n\n plane.style.transformOrigin =\n originY !== undefined\n ? `50% ${originY.toFixed(1)}px`\n : Math.abs(angle) < 0.35\n ? \"50% 50%\"\n : angle > 0\n ? \"50% 100%\"\n : \"50% 0%\"\n plane.style.backfaceVisibility = \"hidden\"\n plane.style.transform = `perspective(${perspective}px) rotateX(${angle}deg)`\n plane.style.filter = \"none\"\n\n const veil = ensureBlurVeil(plane)\n if (blurPx < 0.08) {\n veil.style.cssText = \"display:none\"\n } else {\n const ramp =\n angle >= 0\n ? \"linear-gradient(to top, transparent 0%, transparent 32%, black 100%)\"\n : \"linear-gradient(to bottom, transparent 0%, transparent 32%, black 100%)\"\n veil.style.cssText = [\n \"pointer-events:none\",\n \"position:absolute\",\n \"inset:0\",\n \"z-index:2\",\n `backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `-webkit-backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `mask-image:${ramp}`,\n `-webkit-mask-image:${ramp}`,\n ].join(\";\")\n }\n plane.style.willChange = Math.abs(angle) > 0.08 ? \"transform\" : \"auto\"\n}\n\nfunction animateScroll(\n scroller: HTMLElement | Window,\n to: number,\n durationMs: number,\n signal: AbortSignal\n) {\n const from = readScrollTop(scroller)\n if (durationMs <= 0) {\n if (isWindow(scroller)) window.scrollTo({ top: to })\n else scroller.scrollTop = to\n return Promise.resolve()\n }\n\n const start = performance.now()\n return new Promise((resolve) => {\n const step = (now: number) => {\n if (signal.aborted) {\n resolve()\n return\n }\n const t = Math.min(1, (now - start) / durationMs)\n const e = t * t * t * (t * (t * 6 - 15) + 10)\n const y = from + (to - from) * e\n if (isWindow(scroller)) window.scrollTo({ top: y })\n else scroller.scrollTop = y\n if (t < 1) requestAnimationFrame(step)\n else resolve()\n }\n requestAnimationFrame(step)\n })\n}\n\n/** Lean the page while scrolling down, then spring flat. */\nexport async function playFolioDemo(detail: FolioPlayDetail = {}) {\n if (typeof window === \"undefined\") return\n window.dispatchEvent(new CustomEvent(FOLIO_PLAY, { detail }))\n const hold = detail.holdMs ?? 720\n await new Promise((r) => window.setTimeout(r, hold + 1800))\n}\n\nfunction viewHeightOf(scroller: HTMLElement | Window) {\n return isWindow(scroller) ? window.innerHeight : scroller.clientHeight\n}\n\nfunction ensureBlurVeil(plane: HTMLElement) {\n let veil = plane.querySelector(\"[data-slot='folio-blur-veil']\")\n if (!veil) {\n if (getComputedStyle(plane).position === \"static\") {\n plane.style.position = \"relative\"\n // Marked so destroy() only undoes a position it set itself.\n plane.dataset.folioPositioned = \"\"\n }\n veil = document.createElement(\"div\")\n veil.dataset.slot = \"folio-blur-veil\"\n veil.setAttribute(\"aria-hidden\", \"true\")\n plane.appendChild(veil)\n }\n return veil\n}\n\nexport function createFolio(options: {\n plane: HTMLElement\n scroller: HTMLElement | Window\n demoId?: string\n blur?: number\n perspective?: number\n returnMs?: number\n}): FolioInstance {\n const runtime: FolioRuntimeOptions = {\n blur: options.blur,\n perspective: options.perspective,\n returnMs: options.returnMs,\n }\n\n let reduced = false\n let playing = false\n let destroyed = false\n let lastTop = readScrollTop(options.scroller)\n let lastTime = performance.now()\n let lastWheelAt = 0\n let wheelVel = 0\n let impulse = 0\n let gate = 1\n let gateFrom = 1\n let gateTarget = 1\n let gateT0 = 0\n let gateRaf = 0\n let idleTimer: ReturnType | null = null\n const playAbort = { current: new AbortController() }\n\n const paint = (tilt: number) => {\n applyFolioFrame(\n options.plane,\n tilt,\n runtime,\n reduced,\n hingeY(options.scroller, options.plane, tilt)\n )\n }\n\n const spring = createSpring(paint)\n\n const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const applyReduce = () => {\n reduced = media.matches && !playing\n if (reduced) {\n impulse = 0\n gate = 1\n spring.set(0)\n paint(0)\n }\n }\n applyReduce()\n media.addEventListener(\"change\", applyReduce)\n\n function returnMs() {\n return Math.max(0, runtime.returnMs ?? DEFAULT_RETURN_MS)\n }\n\n function sampleGate(now = performance.now()) {\n const ms = returnMs()\n if (ms <= 0) {\n gate = gateTarget\n return gate\n }\n const t = Math.min(1, (now - gateT0) / ms)\n const e = t * t * (3 - 2 * t)\n gate = gateFrom + (gateTarget - gateFrom) * e\n return gate\n }\n\n function goGate(to: number) {\n if (to === 0) {\n gate = 0\n gateFrom = 0\n gateTarget = 0\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateTarget !== 1) {\n gateFrom = sampleGate()\n gateTarget = 1\n gateT0 = performance.now()\n }\n if (gate >= 0.999) {\n gate = 1\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateRaf) return\n const tick = (now: number) => {\n sampleGate(now)\n applyLean()\n if (gateTarget === 1 && gate < 0.999) {\n gateRaf = requestAnimationFrame(tick)\n } else {\n if (gateTarget === 1) gate = 1\n gateRaf = 0\n }\n }\n gateRaf = requestAnimationFrame(tick)\n }\n\n function applyLean(leavingEdge = false) {\n if (reduced) {\n spring.set(0)\n return\n }\n if (playing) {\n spring.set(tiltAngle(impulse || 1))\n return\n }\n const down = impulse >= 0\n const fade = leavingEdge\n ? 1\n : down\n ? endFade(options.scroller)\n : startFade(options.scroller)\n if (fade <= 0.02) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n goGate(1)\n const g = sampleGate()\n spring.set(tiltAngle(impulse * fade * g))\n }\n\n function lean(amount: number, leavingEdge = false) {\n if (reduced) return\n const next = clampSigned(amount)\n const nextSign = Math.sign(next)\n const prevSign = Math.sign(impulse)\n if (nextSign !== 0 && prevSign !== 0 && nextSign !== prevSign) {\n impulse = next\n } else {\n const dir = nextSign || prevSign || 1\n const mag = Math.max(\n Math.abs(next),\n Math.abs(impulse) * 0.62 + Math.abs(next) * 0.38\n )\n impulse = mag * dir\n }\n applyLean(leavingEdge)\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n impulse = 0\n if (!playing) spring.set(0)\n }, FOLIO_IDLE_MS)\n }\n\n const onWheel = (event: Event) => {\n if (playing) return\n const wheel = event as WheelEvent\n // Lines (Firefox mouse wheels) and pages, in px.\n const dy =\n wheel.deltaMode === 1\n ? wheel.deltaY * 16\n : wheel.deltaMode === 2\n ? wheel.deltaY * viewHeightOf(options.scroller)\n : wheel.deltaY\n if (!dy) return\n const now = performance.now()\n const gap = now - lastWheelAt\n // A lone mouse notch counts as one frame's worth of travel.\n const speed = dy / Math.min(50, Math.max(8, gap))\n wheelVel = gap > WHEEL_GESTURE_GAP_MS ? speed : wheelVel * 0.6 + speed * 0.4\n lastWheelAt = now\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n const leavingEnd = atEnd && dy < 0\n const leavingStart = atStart && dy > 0\n if ((atEnd && !leavingEnd) || (atStart && !leavingStart)) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n lean(clampSigned(wheelVel / WHEEL_VEL_REF), leavingEnd || leavingStart)\n }\n\n const onScroll = () => {\n const now = performance.now()\n const top = readScrollTop(options.scroller)\n const dt = Math.max(8, now - lastTime)\n const vel = (top - lastTop) / dt\n lastTop = top\n lastTime = now\n if (playing) {\n applyLean()\n return\n }\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n if (atEnd && vel >= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (atStart && vel <= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (now - lastWheelAt < WHEEL_SCROLL_LOCK_MS) {\n applyLean(atEnd || atStart)\n return\n }\n lean(clampSigned(vel / VEL_REF), (atEnd && vel < 0) || (atStart && vel > 0))\n }\n\n const wheelTarget: EventTarget = isWindow(options.scroller)\n ? window\n : options.scroller\n wheelTarget.addEventListener(\"wheel\", onWheel, { passive: true })\n options.scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n const onResize = () => paint(spring.get())\n window.addEventListener(\"resize\", onResize)\n\n const onPlay = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n if (options.demoId) {\n if (!detail.target || detail.target !== options.demoId) return\n } else if (detail.target) {\n return\n }\n\n playAbort.current.abort()\n playAbort.current = new AbortController()\n const signal = playAbort.current.signal\n const hold = detail.holdMs ?? 720\n const scroller = detail.scrollRoot ?? options.scroller\n playing = true\n if (idleTimer) clearTimeout(idleTimer)\n applyReduce()\n\n void (async () => {\n const max = maxScroll(scroller)\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n const limit = Math.max(0, max - zone - END_SLACK_PX)\n let start = readScrollTop(scroller)\n if (start >= limit - 24) {\n impulse = 0\n gate = 1\n gateTarget = 1\n spring.set(0)\n if (isWindow(scroller)) window.scrollTo({ top: 0 })\n else scroller.scrollTop = 0\n lastTop = 0\n start = 0\n }\n const dest = Math.min(\n limit,\n start + Math.max(560, viewHeightOf(scroller) * 1.35)\n )\n impulse = 1\n gate = 1\n gateTarget = 1\n spring.set(TILT_PEAK)\n await animateScroll(scroller, dest, Math.max(1400, hold + 700), signal)\n if (signal.aborted) {\n playing = false\n // Aborted by destroy(): leave the plane as destroy() reset it.\n if (!destroyed) applyReduce()\n return\n }\n playing = false\n applyReduce()\n impulse = 0\n spring.set(0)\n })()\n }\n\n window.addEventListener(FOLIO_PLAY, onPlay)\n paint(0)\n\n return {\n setOptions(next) {\n if (next.blur !== undefined) runtime.blur = next.blur\n if (next.perspective !== undefined) runtime.perspective = next.perspective\n if (next.returnMs !== undefined) runtime.returnMs = next.returnMs\n paint(spring.get())\n },\n destroy() {\n destroyed = true\n playing = false\n playAbort.current.abort()\n if (idleTimer) clearTimeout(idleTimer)\n if (gateRaf) cancelAnimationFrame(gateRaf)\n spring.destroy()\n options.plane.querySelector(\"[data-slot='folio-blur-veil']\")?.remove()\n media.removeEventListener(\"change\", applyReduce)\n wheelTarget.removeEventListener(\"wheel\", onWheel)\n options.scroller.removeEventListener(\"scroll\", onScroll)\n window.removeEventListener(\"resize\", onResize)\n window.removeEventListener(FOLIO_PLAY, onPlay)\n options.plane.style.transform = \"\"\n options.plane.style.filter = \"\"\n options.plane.style.willChange = \"\"\n options.plane.style.backfaceVisibility = \"\"\n options.plane.style.transformOrigin = \"\"\n if (\"folioPositioned\" in options.plane.dataset) {\n options.plane.style.position = \"\"\n delete options.plane.dataset.folioPositioned\n }\n },\n }\n}\n\nexport type FolioProps = FolioRuntimeOptions & {\n className?: string\n /** Page content that leans while you scroll. */\n children?: ReactNode\n /**\n * Bind to the window and tilt `contentSelector` instead of wrapping\n * children. Default `false`; this component *is* the scroller.\n */\n windowScroll?: boolean\n /**\n * Element to tilt when `windowScroll` is set.\n * Default `[data-folio-page]`.\n */\n contentSelector?: string\n /** Accessible name for the tilting page. */\n label?: string\n /**\n * Optional id for docs demos. `playFolioDemo({ target })` only\n * animates instances whose `demoId` matches.\n */\n demoId?: string\n}\n\n/**\n * The whole page leans in perspective while you scroll, with a matching\n * blur. When scroll stops, it springs to flat.\n */\nexport function Folio({\n className,\n children,\n blur = 4,\n perspective = 1000,\n returnMs = 520,\n windowScroll = false,\n contentSelector = \"[data-folio-page]\",\n label = \"Tilting page\",\n demoId,\n}: FolioProps) {\n const scrollerRef = useRef(null)\n const planeRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const scroller = windowScroll ? window : scrollerRef.current\n const plane = windowScroll\n ? document.querySelector(contentSelector)\n : planeRef.current\n if (!scroller || !plane) return\n\n instanceRef.current = createFolio({\n plane,\n scroller,\n demoId,\n blur,\n perspective,\n returnMs,\n })\n\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; bind once per scroller mode.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, contentSelector, demoId])\n\n useEffect(() => {\n instanceRef.current?.setOptions({ blur, perspective, returnMs })\n }, [blur, perspective, returnMs])\n\n if (windowScroll) return null\n\n return (\n \n \n {children}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const FOLIO_PLAY = \"folio:play\"\nexport const FOLIO_IDLE_MS = 200\n\nexport type FolioPlayDetail = {\n /** Only instances whose `demoId` matches will play. */\n target?: string\n /** Nested scroller to drive. Omit for `window`. */\n scrollRoot?: HTMLElement | null\n /** How long to hold the lean before springing back, in ms. Default `720`. */\n holdMs?: number\n}\n\nexport type FolioRuntimeOptions = {\n /** Peak blur in px at full tilt. Default `4`. */\n blur?: number\n /** CSS perspective distance in px. Default `1000`. Floor `1000`. */\n perspective?: number\n /**\n * How long the lean takes to come back after the bottom, in ms.\n * Default `520`.\n */\n returnMs?: number\n}\n\nexport type FolioInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nconst TILT_PEAK = 16\nconst DEFAULT_BLUR = 4\nconst DEFAULT_PERSPECTIVE = 1000\nconst MIN_PERSPECTIVE = 1000\nconst DEFAULT_RETURN_MS = 520\nconst VEL_REF = 2.4\nconst WHEEL_REF = 110\n/**\n * Wheel speed, in px/ms, that leans all the way. Trackpads send a stream\n * of small deltas, so a lean read per event barely moved; speed reads a\n * fast swipe and a fast mouse flick the same.\n */\nconst WHEEL_VEL_REF = 1.1\n/** A wheel gap longer than this starts a fresh gesture. */\nconst WHEEL_GESTURE_GAP_MS = 150\nconst WHEEL_SCROLL_LOCK_MS = 64\nconst END_SLACK_PX = 8\nconst SPRING_IN = { stiffness: 72, damping: 22 }\nconst SPRING_OUT = { stiffness: 42, damping: 18 }\n\ntype Spring = {\n set: (value: number) => void\n get: () => number\n destroy: () => void\n}\n\nfunction createSpring(onChange: (value: number) => void): Spring {\n let current = 0\n let target = 0\n let velocity = 0\n let raf = 0\n let lastTime = 0\n let stiffness = SPRING_IN.stiffness\n let damping = SPRING_IN.damping\n\n function integrate(dt: number) {\n const accel = -stiffness * (current - target) - damping * velocity\n velocity += accel * dt\n current += velocity * dt\n }\n\n function tick(now: number) {\n if (!lastTime) lastTime = now\n let remaining = Math.min((now - lastTime) / 1000, 0.048)\n lastTime = now\n const step = 1 / 60\n while (remaining > 0) {\n integrate(Math.min(step, remaining))\n remaining -= step\n }\n const settled =\n Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03\n if (settled) {\n current = target\n velocity = 0\n raf = 0\n lastTime = 0\n onChange(current)\n return\n }\n onChange(current)\n raf = requestAnimationFrame(tick)\n }\n\n function start() {\n if (!raf) {\n lastTime = 0\n raf = requestAnimationFrame(tick)\n }\n }\n\n return {\n set(value) {\n target = value\n if (value === 0) {\n stiffness = SPRING_OUT.stiffness\n damping = SPRING_OUT.damping\n } else {\n stiffness = SPRING_IN.stiffness\n damping = SPRING_IN.damping\n }\n if (Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03) {\n current = target\n velocity = 0\n onChange(current)\n return\n }\n start()\n },\n get() {\n return current\n },\n destroy() {\n if (raf) cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\nfunction isWindow(scroller: HTMLElement | Window): scroller is Window {\n return scroller === window\n}\n\nfunction readScrollTop(scroller: HTMLElement | Window) {\n return isWindow(scroller)\n ? window.scrollY || document.documentElement.scrollTop\n : scroller.scrollTop\n}\n\nfunction maxScroll(scroller: HTMLElement | Window) {\n if (isWindow(scroller)) {\n return Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n }\n return Math.max(0, scroller.scrollHeight - scroller.clientHeight)\n}\n\nfunction clamp01(n: number) {\n return Math.max(0, Math.min(1, n))\n}\n\nfunction clampSigned(n: number) {\n return Math.max(-1, Math.min(1, n))\n}\n\nfunction remainingScroll(scroller: HTMLElement | Window) {\n return Math.max(0, maxScroll(scroller) - readScrollTop(scroller))\n}\n\n/** `1` in the body of the page, `0` at the bottom so the down-lean dies. */\nfunction endFade(scroller: HTMLElement | Window) {\n const left = remainingScroll(scroller)\n if (left <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((left - END_SLACK_PX) / zone)\n}\n\n/** `1` in the body of the page, `0` at the top so the up-lean dies. */\nfunction startFade(scroller: HTMLElement | Window) {\n const top = readScrollTop(scroller)\n if (top <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((top - END_SLACK_PX) / zone)\n}\n\n/** Map scroll delta to a signed -1…1 lean. Positive = scrolling down. */\nexport function leanFromDelta(deltaPx: number, ref = WHEEL_REF) {\n return clampSigned(deltaPx / ref)\n}\n\n/** Slow travel sits near 5°, a flick reaches 16°. */\nfunction tiltAngle(impulse: number) {\n const mag = Math.min(1, Math.abs(impulse))\n const deg = mag * mag * (TILT_PEAK - 5) + mag * 5\n return deg * Math.sign(impulse || 1)\n}\n\nfunction hingeY(\n scroller: HTMLElement | Window,\n plane: HTMLElement,\n tilt: number\n) {\n const vh = viewHeightOf(scroller)\n const localViewTop = readScrollTop(scroller) - plane.offsetTop\n if (Math.abs(tilt) < 0.35) return localViewTop + vh * 0.5\n return tilt > 0 ? localViewTop + vh : localViewTop\n}\n\nexport function applyFolioFrame(\n plane: HTMLElement,\n tilt: number,\n options: FolioRuntimeOptions = {},\n reducedMotion = false,\n originY?: number\n) {\n const blur = Math.max(0, options.blur ?? DEFAULT_BLUR)\n const perspective = Math.max(\n MIN_PERSPECTIVE,\n options.perspective ?? DEFAULT_PERSPECTIVE\n )\n const angle = reducedMotion ? 0 : tilt\n const amount = Math.min(1, Math.abs(angle) / TILT_PEAK)\n const blurPx = reducedMotion ? 0 : amount * blur\n\n plane.style.transformOrigin =\n originY !== undefined\n ? `50% ${originY.toFixed(1)}px`\n : Math.abs(angle) < 0.35\n ? \"50% 50%\"\n : angle > 0\n ? \"50% 100%\"\n : \"50% 0%\"\n plane.style.backfaceVisibility = \"hidden\"\n plane.style.transform = `perspective(${perspective}px) rotateX(${angle}deg)`\n plane.style.filter = \"none\"\n\n const veil = ensureBlurVeil(plane)\n if (blurPx < 0.08) {\n veil.style.cssText = \"display:none\"\n } else {\n const ramp =\n angle >= 0\n ? \"linear-gradient(to top, transparent 0%, transparent 32%, black 100%)\"\n : \"linear-gradient(to bottom, transparent 0%, transparent 32%, black 100%)\"\n veil.style.cssText = [\n \"pointer-events:none\",\n \"position:absolute\",\n \"inset:0\",\n \"z-index:2\",\n `backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `-webkit-backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `mask-image:${ramp}`,\n `-webkit-mask-image:${ramp}`,\n ].join(\";\")\n }\n plane.style.willChange = Math.abs(angle) > 0.08 ? \"transform\" : \"auto\"\n}\n\nfunction animateScroll(\n scroller: HTMLElement | Window,\n to: number,\n durationMs: number,\n signal: AbortSignal\n) {\n const from = readScrollTop(scroller)\n if (durationMs <= 0) {\n if (isWindow(scroller)) window.scrollTo({ top: to })\n else scroller.scrollTop = to\n return Promise.resolve()\n }\n\n const start = performance.now()\n return new Promise((resolve) => {\n const step = (now: number) => {\n if (signal.aborted) {\n resolve()\n return\n }\n const t = Math.min(1, (now - start) / durationMs)\n const e = t * t * t * (t * (t * 6 - 15) + 10)\n const y = from + (to - from) * e\n if (isWindow(scroller)) window.scrollTo({ top: y })\n else scroller.scrollTop = y\n if (t < 1) requestAnimationFrame(step)\n else resolve()\n }\n requestAnimationFrame(step)\n })\n}\n\n/** Lean the page while scrolling down, then spring flat. */\nexport async function playFolioDemo(detail: FolioPlayDetail = {}) {\n if (typeof window === \"undefined\") return\n window.dispatchEvent(new CustomEvent(FOLIO_PLAY, { detail }))\n const hold = detail.holdMs ?? 720\n await new Promise((r) => window.setTimeout(r, hold + 1800))\n}\n\nfunction viewHeightOf(scroller: HTMLElement | Window) {\n return isWindow(scroller) ? window.innerHeight : scroller.clientHeight\n}\n\nfunction ensureBlurVeil(plane: HTMLElement) {\n let veil = plane.querySelector(\"[data-slot='folio-blur-veil']\")\n if (!veil) {\n if (getComputedStyle(plane).position === \"static\") {\n plane.style.position = \"relative\"\n // Marked so destroy() only undoes a position it set itself.\n plane.dataset.folioPositioned = \"\"\n }\n veil = document.createElement(\"div\")\n veil.dataset.slot = \"folio-blur-veil\"\n veil.setAttribute(\"aria-hidden\", \"true\")\n plane.appendChild(veil)\n }\n return veil\n}\n\nexport function createFolio(options: {\n plane: HTMLElement\n scroller: HTMLElement | Window\n demoId?: string\n blur?: number\n perspective?: number\n returnMs?: number\n}): FolioInstance {\n const runtime: FolioRuntimeOptions = {\n blur: options.blur,\n perspective: options.perspective,\n returnMs: options.returnMs,\n }\n\n let reduced = false\n let playing = false\n let destroyed = false\n let lastTop = readScrollTop(options.scroller)\n let lastTime = performance.now()\n let lastWheelAt = 0\n let wheelVel = 0\n let impulse = 0\n let gate = 1\n let gateFrom = 1\n let gateTarget = 1\n let gateT0 = 0\n let gateRaf = 0\n let idleTimer: ReturnType | null = null\n const playAbort = { current: new AbortController() }\n\n const paint = (tilt: number) => {\n applyFolioFrame(\n options.plane,\n tilt,\n runtime,\n reduced,\n hingeY(options.scroller, options.plane, tilt)\n )\n }\n\n const spring = createSpring(paint)\n\n const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const applyReduce = () => {\n reduced = media.matches && !playing\n if (reduced) {\n impulse = 0\n gate = 1\n spring.set(0)\n paint(0)\n }\n }\n applyReduce()\n media.addEventListener(\"change\", applyReduce)\n\n function returnMs() {\n return Math.max(0, runtime.returnMs ?? DEFAULT_RETURN_MS)\n }\n\n function sampleGate(now = performance.now()) {\n const ms = returnMs()\n if (ms <= 0) {\n gate = gateTarget\n return gate\n }\n const t = Math.min(1, (now - gateT0) / ms)\n const e = t * t * (3 - 2 * t)\n gate = gateFrom + (gateTarget - gateFrom) * e\n return gate\n }\n\n function goGate(to: number) {\n if (to === 0) {\n gate = 0\n gateFrom = 0\n gateTarget = 0\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateTarget !== 1) {\n gateFrom = sampleGate()\n gateTarget = 1\n gateT0 = performance.now()\n }\n if (gate >= 0.999) {\n gate = 1\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateRaf) return\n const tick = (now: number) => {\n sampleGate(now)\n applyLean()\n if (gateTarget === 1 && gate < 0.999) {\n gateRaf = requestAnimationFrame(tick)\n } else {\n if (gateTarget === 1) gate = 1\n gateRaf = 0\n }\n }\n gateRaf = requestAnimationFrame(tick)\n }\n\n function applyLean(leavingEdge = false) {\n if (reduced) {\n spring.set(0)\n return\n }\n if (playing) {\n spring.set(tiltAngle(impulse || 1))\n return\n }\n const down = impulse >= 0\n const fade = leavingEdge\n ? 1\n : down\n ? endFade(options.scroller)\n : startFade(options.scroller)\n if (fade <= 0.02) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n goGate(1)\n const g = sampleGate()\n spring.set(tiltAngle(impulse * fade * g))\n }\n\n function lean(amount: number, leavingEdge = false) {\n if (reduced) return\n const next = clampSigned(amount)\n const nextSign = Math.sign(next)\n const prevSign = Math.sign(impulse)\n if (nextSign !== 0 && prevSign !== 0 && nextSign !== prevSign) {\n impulse = next\n } else {\n const dir = nextSign || prevSign || 1\n const mag = Math.max(\n Math.abs(next),\n Math.abs(impulse) * 0.62 + Math.abs(next) * 0.38\n )\n impulse = mag * dir\n }\n applyLean(leavingEdge)\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n impulse = 0\n if (!playing) spring.set(0)\n }, FOLIO_IDLE_MS)\n }\n\n const onWheel = (event: Event) => {\n if (playing) return\n const wheel = event as WheelEvent\n // Lines (Firefox mouse wheels) and pages, in px.\n const dy =\n wheel.deltaMode === 1\n ? wheel.deltaY * 16\n : wheel.deltaMode === 2\n ? wheel.deltaY * viewHeightOf(options.scroller)\n : wheel.deltaY\n if (!dy) return\n const now = performance.now()\n const gap = now - lastWheelAt\n // A lone mouse notch counts as one frame's worth of travel.\n const speed = dy / Math.min(50, Math.max(8, gap))\n wheelVel = gap > WHEEL_GESTURE_GAP_MS ? speed : wheelVel * 0.6 + speed * 0.4\n lastWheelAt = now\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n const leavingEnd = atEnd && dy < 0\n const leavingStart = atStart && dy > 0\n if ((atEnd && !leavingEnd) || (atStart && !leavingStart)) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n lean(clampSigned(wheelVel / WHEEL_VEL_REF), leavingEnd || leavingStart)\n }\n\n const onScroll = () => {\n const now = performance.now()\n const top = readScrollTop(options.scroller)\n const dt = Math.max(8, now - lastTime)\n const vel = (top - lastTop) / dt\n lastTop = top\n lastTime = now\n if (playing) {\n applyLean()\n return\n }\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n if (atEnd && vel >= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (atStart && vel <= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (now - lastWheelAt < WHEEL_SCROLL_LOCK_MS) {\n applyLean(atEnd || atStart)\n return\n }\n lean(clampSigned(vel / VEL_REF), (atEnd && vel < 0) || (atStart && vel > 0))\n }\n\n const wheelTarget: EventTarget = isWindow(options.scroller)\n ? window\n : options.scroller\n wheelTarget.addEventListener(\"wheel\", onWheel, { passive: true })\n options.scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n const onResize = () => paint(spring.get())\n window.addEventListener(\"resize\", onResize)\n\n const onPlay = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n if (options.demoId) {\n if (!detail.target || detail.target !== options.demoId) return\n } else if (detail.target) {\n return\n }\n\n playAbort.current.abort()\n playAbort.current = new AbortController()\n const signal = playAbort.current.signal\n const hold = detail.holdMs ?? 720\n const scroller = detail.scrollRoot ?? options.scroller\n playing = true\n if (idleTimer) clearTimeout(idleTimer)\n applyReduce()\n\n void (async () => {\n const max = maxScroll(scroller)\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n const limit = Math.max(0, max - zone - END_SLACK_PX)\n let start = readScrollTop(scroller)\n if (start >= limit - 24) {\n impulse = 0\n gate = 1\n gateTarget = 1\n spring.set(0)\n if (isWindow(scroller)) window.scrollTo({ top: 0 })\n else scroller.scrollTop = 0\n lastTop = 0\n start = 0\n }\n const dest = Math.min(\n limit,\n start + Math.max(560, viewHeightOf(scroller) * 1.35)\n )\n impulse = 1\n gate = 1\n gateTarget = 1\n spring.set(TILT_PEAK)\n await animateScroll(scroller, dest, Math.max(1400, hold + 700), signal)\n if (signal.aborted) {\n playing = false\n // Aborted by destroy(): leave the plane as destroy() reset it.\n if (!destroyed) applyReduce()\n return\n }\n playing = false\n applyReduce()\n impulse = 0\n spring.set(0)\n })()\n }\n\n window.addEventListener(FOLIO_PLAY, onPlay)\n paint(0)\n\n return {\n setOptions(next) {\n if (next.blur !== undefined) runtime.blur = next.blur\n if (next.perspective !== undefined) runtime.perspective = next.perspective\n if (next.returnMs !== undefined) runtime.returnMs = next.returnMs\n paint(spring.get())\n },\n destroy() {\n destroyed = true\n playing = false\n playAbort.current.abort()\n if (idleTimer) clearTimeout(idleTimer)\n if (gateRaf) cancelAnimationFrame(gateRaf)\n spring.destroy()\n options.plane.querySelector(\"[data-slot='folio-blur-veil']\")?.remove()\n media.removeEventListener(\"change\", applyReduce)\n wheelTarget.removeEventListener(\"wheel\", onWheel)\n options.scroller.removeEventListener(\"scroll\", onScroll)\n window.removeEventListener(\"resize\", onResize)\n window.removeEventListener(FOLIO_PLAY, onPlay)\n options.plane.style.transform = \"\"\n options.plane.style.filter = \"\"\n options.plane.style.willChange = \"\"\n options.plane.style.backfaceVisibility = \"\"\n options.plane.style.transformOrigin = \"\"\n if (\"folioPositioned\" in options.plane.dataset) {\n options.plane.style.position = \"\"\n delete options.plane.dataset.folioPositioned\n }\n },\n }\n}\n\nexport type FolioProps = FolioRuntimeOptions & {\n className?: string\n /** Page content that leans while you scroll. */\n children?: ReactNode\n /**\n * Bind to the window and tilt `contentSelector` instead of wrapping\n * children. Default `false`; this component *is* the scroller.\n */\n windowScroll?: boolean\n /**\n * Element to tilt when `windowScroll` is set.\n * Default `[data-folio-page]`.\n */\n contentSelector?: string\n /** Accessible name for the tilting page. */\n label?: string\n /**\n * Optional id for docs demos. `playFolioDemo({ target })` only\n * animates instances whose `demoId` matches.\n */\n demoId?: string\n}\n\n/**\n * The whole page leans in perspective while you scroll, with a matching\n * blur. When scroll stops, it springs to flat.\n */\nexport function Folio({\n className,\n children,\n blur = 4,\n perspective = 1000,\n returnMs = 520,\n windowScroll = false,\n contentSelector = \"[data-folio-page]\",\n label = \"Tilting page\",\n demoId,\n}: FolioProps) {\n const scrollerRef = useRef(null)\n const planeRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const scroller = windowScroll ? window : scrollerRef.current\n const plane = windowScroll\n ? document.querySelector(contentSelector)\n : planeRef.current\n if (!scroller || !plane) return\n\n instanceRef.current = createFolio({\n plane,\n scroller,\n demoId,\n blur,\n perspective,\n returnMs,\n })\n\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; bind once per scroller mode.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, contentSelector, demoId])\n\n useEffect(() => {\n instanceRef.current?.setOptions({ blur, perspective, returnMs })\n }, [blur, perspective, returnMs])\n\n if (windowScroll) return null\n\n return (\n \n \n {children}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/folio.tsx" } diff --git a/public/r/gooey-color-picker-svelte.json b/public/r/gooey-color-picker-svelte.json index 4d8e5be..94333c7 100644 --- a/public/r/gooey-color-picker-svelte.json +++ b/public/r/gooey-color-picker-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/gooey-color-picker/gooey-color-picker.svelte", - "content": "\n\n\n\n\n \n updateWheelFromPointer(x, y))}\n onkeydown={onWheelKeyDown}\n >\n \n \n\n updateAlphaFromPointer(x))}\n onkeydown={onAlphaKeyDown}\n >\n \n \n\n \n \n {\n hexFocused = true\n }}\n onblur={() => {\n hexFocused = false\n commitHex(hexDraft)\n }}\n onkeydown={onHexKeyDown}\n class=\"min-w-0 flex-1 bg-transparent font-mono text-sm text-white outline-none placeholder:text-white/40\"\n />\n {#if supportsEyeDropper}\n \n \n \n \n \n \n \n {/if}\n \n \n\n {\n open = !open\n }}\n >\n \n {#if open}\n {#each CLOSE_PATHS as d}\n \n {/each}\n {:else}\n {#each PALETTE_PATHS as d}\n \n {/each}\n {/if}\n \n \n\n\n\n", + "content": "\n\n\n\n\n \n updateWheelFromPointer(x, y))}\n onkeydown={onWheelKeyDown}\n >\n \n \n\n updateAlphaFromPointer(x))}\n onkeydown={onAlphaKeyDown}\n >\n \n \n\n \n \n {\n hexFocused = true\n }}\n onblur={() => {\n hexFocused = false\n commitHex(hexDraft)\n }}\n onkeydown={onHexKeyDown}\n class=\"min-w-0 flex-1 bg-transparent font-mono text-sm text-popover-foreground outline-none placeholder:text-muted-foreground\"\n />\n {#if supportsEyeDropper}\n \n \n \n \n \n \n \n {/if}\n \n \n\n {\n open = !open\n }}\n >\n \n {#if open}\n {#each CLOSE_PATHS as d}\n \n {/each}\n {:else}\n {#each PALETTE_PATHS as d}\n \n {/each}\n {/if}\n \n \n\n\n\n", "type": "registry:file", "target": "src/lib/components/ui/gooey-color-picker.svelte" } diff --git a/public/r/gooey-color-picker.json b/public/r/gooey-color-picker.json index 41563c3..f5081e4 100644 --- a/public/r/gooey-color-picker.json +++ b/public/r/gooey-color-picker.json @@ -9,7 +9,7 @@ "files": [ { "path": "registry/gooey-color-picker/gooey-color-picker.tsx", - "content": "\"use client\"\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type PointerEvent as ReactPointerEvent,\n} from \"react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type GooeyColor = {\n h: number\n s: number\n l: number\n a: number\n}\n\nexport const DEFAULT_COLOR: GooeyColor = { h: 320, s: 90, l: 58, a: 1 }\n\n/** Stroked palette (closed) */\nexport const PALETTE_PATHS = [\n \"M12 3.5c3.7 0 6.75 2.85 6.75 6.5 0 1.2-.95 2.2-2.15 2.2h-.35c-.7 0-1.25.55-1.25 1.25V14c0 2.35-1.9 4.25-4.25 4.25S6.5 16.35 6.5 14 8.4 9.75 10.75 9.75h.5\",\n \"M8.2 8.1a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n \"M11.2 6.4a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n \"M14.6 6.9a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n \"M16.9 9.2a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n]\n\n/** Close X (open) */\nexport const CLOSE_PATHS = [\"M7 7L17 17\", \"M17 7L7 17\"]\n\nexport function clamp(n: number, min: number, max: number) {\n return Math.min(max, Math.max(min, n))\n}\n\nexport function isHexColor(value: string) {\n const hex = value.trim().replace(/^#/, \"\")\n return /^(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)\n}\n\nexport function toCss(color: GooeyColor) {\n return `hsla(${Math.round(color.h)} ${Math.round(color.s)}% ${Math.round(color.l)}% / ${Number(color.a.toFixed(3))})`\n}\n\nexport function hslToRgb(h: number, s: number, l: number) {\n const sn = s / 100\n const ln = l / 100\n const c = (1 - Math.abs(2 * ln - 1)) * sn\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n const m = ln - c / 2\n let r = 0\n let g = 0\n let b = 0\n if (h < 60) [r, g, b] = [c, x, 0]\n else if (h < 120) [r, g, b] = [x, c, 0]\n else if (h < 180) [r, g, b] = [0, c, x]\n else if (h < 240) [r, g, b] = [0, x, c]\n else if (h < 300) [r, g, b] = [x, 0, c]\n else [r, g, b] = [c, 0, x]\n return {\n r: Math.round((r + m) * 255),\n g: Math.round((g + m) * 255),\n b: Math.round((b + m) * 255),\n }\n}\n\nexport function toHex(color: GooeyColor) {\n const { r, g, b } = hslToRgb(color.h, color.s, color.l)\n const rgb = [r, g, b]\n .map((n) => n.toString(16).padStart(2, \"0\"))\n .join(\"\")\n if (color.a >= 0.999) return `#${rgb}`\n const a = Math.round(color.a * 255)\n .toString(16)\n .padStart(2, \"0\")\n return `#${rgb}${a}`\n}\n\nexport function rgbToHsl(r: number, g: number, b: number): Omit {\n const rn = r / 255\n const gn = g / 255\n const bn = b / 255\n const max = Math.max(rn, gn, bn)\n const min = Math.min(rn, gn, bn)\n const l = (max + min) / 2\n if (max === min) return { h: 0, s: 0, l: l * 100 }\n\n const d = max - min\n const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n let h = 0\n switch (max) {\n case rn:\n h = (gn - bn) / d + (gn < bn ? 6 : 0)\n break\n case gn:\n h = (bn - rn) / d + 2\n break\n default:\n h = (rn - gn) / d + 4\n }\n return { h: (h / 6) * 360, s: s * 100, l: l * 100 }\n}\n\nexport function parseColor(input?: GooeyColor | string): GooeyColor {\n if (!input) return { ...DEFAULT_COLOR }\n if (typeof input !== \"string\") {\n return {\n h: clamp(input.h, 0, 360),\n s: clamp(input.s, 0, 100),\n l: clamp(input.l, 0, 100),\n a: clamp(input.a, 0, 1),\n }\n }\n\n const hsla =\n input.match(\n /hsla?\\(\\s*([\\d.]+)(?:deg)?[\\s,]+([\\d.]+)%[\\s,]+([\\d.]+)%(?:[\\s,/]+([\\d.]+)%?)?\\s*\\)/i\n ) ?? null\n if (hsla) {\n const aRaw = hsla[4] == null ? 1 : Number(hsla[4])\n return {\n h: clamp(Number(hsla[1]), 0, 360),\n s: clamp(Number(hsla[2]), 0, 100),\n l: clamp(Number(hsla[3]), 0, 100),\n a: clamp(aRaw > 1 ? aRaw / 100 : aRaw, 0, 1),\n }\n }\n\n const hex = input.trim().replace(/^#/, \"\").trim()\n if (isHexColor(hex)) {\n const full =\n hex.length === 3 || hex.length === 4\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex\n const r = Number.parseInt(full.slice(0, 2), 16)\n const g = Number.parseInt(full.slice(2, 4), 16)\n const b = Number.parseInt(full.slice(4, 6), 16)\n const a =\n full.length >= 8 ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1\n return { ...rgbToHsl(r, g, b), a: clamp(a, 0, 1) }\n }\n\n return { ...DEFAULT_COLOR }\n}\n\nexport type GooeyColorPickerProps = {\n value?: GooeyColor | string\n defaultValue?: GooeyColor | string\n onChange?: (color: GooeyColor, css: string) => void\n className?: string\n /** Accessible name for the trigger */\n label?: string\n}\n\n/** Minimal typing for the (still non-standard) EyeDropper API. */\ntype EyeDropperResult = { sRGBHex: string }\ntype EyeDropperConstructor = new () => {\n open: (options?: { signal?: AbortSignal }) => Promise\n}\n\nconst spring = {\n type: \"spring\" as const,\n stiffness: 420,\n damping: 32,\n mass: 0.85,\n}\n\nconst pathTransition = {\n duration: 0.32,\n ease: [0.22, 1, 0.36, 1] as const,\n}\n\nfunction useControllableColor(\n value: GooeyColorPickerProps[\"value\"],\n defaultValue: GooeyColorPickerProps[\"defaultValue\"],\n onChange: GooeyColorPickerProps[\"onChange\"]\n) {\n const [uncontrolled, setUncontrolled] = useState(() =>\n parseColor(defaultValue ?? value)\n )\n const isControlled = value !== undefined\n const color = isControlled ? parseColor(value) : uncontrolled\n\n const setColor = useCallback(\n (next: GooeyColor) => {\n if (!isControlled) setUncontrolled(next)\n onChange?.(next, toCss(next))\n },\n [isControlled, onChange]\n )\n\n return [color, setColor] as const\n}\n\nfunction startDrag(\n event: ReactPointerEvent,\n onMove: (clientX: number, clientY: number) => void\n) {\n event.preventDefault()\n const target = event.currentTarget\n const pointerId = event.pointerId\n target.setPointerCapture(pointerId)\n onMove(event.clientX, event.clientY)\n\n function move(e: PointerEvent) {\n if (e.pointerId === pointerId) onMove(e.clientX, e.clientY)\n }\n // A drag ends on release, on a cancelled touch, or when capture is lost,\n // which is also what happens if the picker unmounts mid-drag.\n function end(e: PointerEvent) {\n if (e.pointerId !== pointerId) return\n window.removeEventListener(\"pointermove\", move)\n window.removeEventListener(\"pointerup\", end)\n window.removeEventListener(\"pointercancel\", end)\n document.removeEventListener(\"lostpointercapture\", end, true)\n try {\n target.releasePointerCapture(pointerId)\n } catch {\n // ignore\n }\n }\n window.addEventListener(\"pointermove\", move)\n window.addEventListener(\"pointerup\", end)\n window.addEventListener(\"pointercancel\", end)\n document.addEventListener(\"lostpointercapture\", end, true)\n}\n\nconst noopSubscribe = () => () => {}\nconst hasEyeDropper = () => \"EyeDropper\" in window\nconst noEyeDropper = () => false\n\nfunction TriggerIcon({ open }: { open: boolean }) {\n const paths = open ? CLOSE_PATHS : PALETTE_PATHS\n\n return (\n \n \n \n {paths.map((d, i) => (\n \n ))}\n \n \n \n )\n}\n\n/** Minimal pipette / eyedropper glyph. */\nfunction EyeDropperIcon() {\n return (\n \n \n \n \n \n )\n}\n\nexport function GooeyColorPicker({\n value,\n defaultValue,\n onChange,\n className,\n label = \"Color picker\",\n}: GooeyColorPickerProps) {\n const rootRef = useRef(null)\n const wheelRef = useRef(null)\n const alphaRef = useRef(null)\n const [open, setOpen] = useState(false)\n const [color, setColor] = useControllableColor(value, defaultValue, onChange)\n // Drag and eyedropper handlers read the latest color without re-binding.\n const colorRef = useRef(color)\n useLayoutEffect(() => {\n colorRef.current = color\n })\n const css = toCss(color)\n const opaque = toCss({ ...color, a: 1 })\n const hex = toHex(color)\n const [hexDraft, setHexDraft] = useState(hex)\n const hexFocused = useRef(false)\n const supportsEyeDropper = useSyncExternalStore(\n noopSubscribe,\n hasEyeDropper,\n noEyeDropper\n )\n\n useEffect(() => {\n if (!hexFocused.current) setHexDraft(hex)\n }, [hex])\n\n // Commit a color from any picker surface (wheel, alpha, eyedropper) and keep\n // the code field in sync, even while the input is focused.\n const applyColor = useCallback(\n (next: GooeyColor) => {\n setColor(next)\n setHexDraft(toHex(next))\n },\n [setColor]\n )\n\n const pickWithEyeDropper = useCallback(async () => {\n const Ctor = (window as unknown as { EyeDropper?: EyeDropperConstructor })\n .EyeDropper\n if (!Ctor) return\n try {\n const { sRGBHex } = await new Ctor().open()\n applyColor({ ...parseColor(sRGBHex), a: colorRef.current.a })\n } catch {\n // user dismissed the eyedropper; no-op\n }\n }, [applyColor])\n\n function commitHex(raw: string) {\n const next = parseColor(raw.startsWith(\"#\") ? raw : `#${raw}`)\n const normalized = toHex(next)\n if (isHexColor(raw.trim())) {\n setColor({ ...next })\n setHexDraft(normalized)\n return\n }\n setHexDraft(hex)\n }\n\n function togglePicker() {\n setOpen((v) => !v)\n }\n\n function closePicker() {\n setOpen(false)\n }\n\n useEffect(() => {\n if (!open) return\n function onKeyDown(event: KeyboardEvent) {\n if (event.key === \"Escape\") closePicker()\n }\n window.addEventListener(\"keydown\", onKeyDown)\n return () => window.removeEventListener(\"keydown\", onKeyDown)\n }, [open])\n\n useEffect(() => {\n if (!open) return\n function onPointerDown(event: PointerEvent) {\n if (!rootRef.current?.contains(event.target as Node)) {\n closePicker()\n }\n }\n window.addEventListener(\"pointerdown\", onPointerDown)\n return () => window.removeEventListener(\"pointerdown\", onPointerDown)\n }, [open])\n\n const updateWheelFromPointer = useCallback(\n (clientX: number, clientY: number) => {\n const el = wheelRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const cx = rect.left + rect.width / 2\n const cy = rect.top + rect.height / 2\n const dx = clientX - cx\n const dy = clientY - cy\n const radius = rect.width / 2\n const dist = Math.min(Math.hypot(dx, dy) / radius, 1)\n const angle = (Math.atan2(dy, dx) * 180) / Math.PI\n const h = (angle + 90 + 360) % 360\n const s = clamp(dist * 100, 0, 100)\n applyColor({ ...colorRef.current, h, s, l: 55 })\n },\n [applyColor]\n )\n\n const updateAlphaFromPointer = useCallback(\n (clientX: number) => {\n const el = alphaRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const t = clamp((clientX - rect.left) / rect.width, 0, 1)\n applyColor({ ...colorRef.current, a: t })\n },\n [applyColor]\n )\n\n const wheelThumb = {\n x: 50 + Math.sin((color.h * Math.PI) / 180) * color.s * 0.42,\n y: 50 - Math.cos((color.h * Math.PI) / 180) * color.s * 0.42,\n }\n\n return (\n \n \n startDrag(event, updateWheelFromPointer)}\n onKeyDown={(event) => {\n if (event.key === \"ArrowLeft\") {\n event.preventDefault()\n applyColor({ ...color, h: (color.h + 350) % 360 })\n }\n if (event.key === \"ArrowRight\") {\n event.preventDefault()\n applyColor({ ...color, h: (color.h + 10) % 360 })\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault()\n applyColor({ ...color, s: clamp(color.s + 5, 0, 100) })\n }\n if (event.key === \"ArrowDown\") {\n event.preventDefault()\n applyColor({ ...color, s: clamp(color.s - 5, 0, 100) })\n }\n }}\n >\n \n \n\n startDrag(event, updateAlphaFromPointer)}\n onKeyDown={(event) => {\n if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") {\n event.preventDefault()\n applyColor({ ...color, a: clamp(color.a + 0.05, 0, 1) })\n }\n if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") {\n event.preventDefault()\n applyColor({ ...color, a: clamp(color.a - 0.05, 0, 1) })\n }\n }}\n >\n \n \n\n
    \n \n {\n hexFocused.current = true\n }}\n onChange={(event) => setHexDraft(event.target.value)}\n onBlur={() => {\n hexFocused.current = false\n commitHex(hexDraft)\n }}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.currentTarget.blur()\n }\n if (event.key === \"Escape\") {\n setHexDraft(hex)\n event.currentTarget.blur()\n }\n }}\n className=\"min-w-0 flex-1 bg-transparent font-mono text-sm text-white outline-none placeholder:text-white/40\"\n />\n {supportsEyeDropper ? (\n \n \n \n ) : null}\n
    \n \n\n \n \n \n \n )\n}\n\nexport default GooeyColorPicker\n", + "content": "\"use client\"\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type PointerEvent as ReactPointerEvent,\n} from \"react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type GooeyColor = {\n h: number\n s: number\n l: number\n a: number\n}\n\nexport const DEFAULT_COLOR: GooeyColor = { h: 320, s: 90, l: 58, a: 1 }\n\n/** Stroked palette (closed) */\nexport const PALETTE_PATHS = [\n \"M12 3.5c3.7 0 6.75 2.85 6.75 6.5 0 1.2-.95 2.2-2.15 2.2h-.35c-.7 0-1.25.55-1.25 1.25V14c0 2.35-1.9 4.25-4.25 4.25S6.5 16.35 6.5 14 8.4 9.75 10.75 9.75h.5\",\n \"M8.2 8.1a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n \"M11.2 6.4a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n \"M14.6 6.9a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n \"M16.9 9.2a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8Z\",\n]\n\n/** Close X (open) */\nexport const CLOSE_PATHS = [\"M7 7L17 17\", \"M17 7L7 17\"]\n\nexport function clamp(n: number, min: number, max: number) {\n return Math.min(max, Math.max(min, n))\n}\n\nexport function isHexColor(value: string) {\n const hex = value.trim().replace(/^#/, \"\")\n return /^(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)\n}\n\nexport function toCss(color: GooeyColor) {\n return `hsla(${Math.round(color.h)} ${Math.round(color.s)}% ${Math.round(color.l)}% / ${Number(color.a.toFixed(3))})`\n}\n\nexport function hslToRgb(h: number, s: number, l: number) {\n const sn = s / 100\n const ln = l / 100\n const c = (1 - Math.abs(2 * ln - 1)) * sn\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n const m = ln - c / 2\n let r = 0\n let g = 0\n let b = 0\n if (h < 60) [r, g, b] = [c, x, 0]\n else if (h < 120) [r, g, b] = [x, c, 0]\n else if (h < 180) [r, g, b] = [0, c, x]\n else if (h < 240) [r, g, b] = [0, x, c]\n else if (h < 300) [r, g, b] = [x, 0, c]\n else [r, g, b] = [c, 0, x]\n return {\n r: Math.round((r + m) * 255),\n g: Math.round((g + m) * 255),\n b: Math.round((b + m) * 255),\n }\n}\n\nexport function toHex(color: GooeyColor) {\n const { r, g, b } = hslToRgb(color.h, color.s, color.l)\n const rgb = [r, g, b]\n .map((n) => n.toString(16).padStart(2, \"0\"))\n .join(\"\")\n if (color.a >= 0.999) return `#${rgb}`\n const a = Math.round(color.a * 255)\n .toString(16)\n .padStart(2, \"0\")\n return `#${rgb}${a}`\n}\n\nexport function rgbToHsl(r: number, g: number, b: number): Omit {\n const rn = r / 255\n const gn = g / 255\n const bn = b / 255\n const max = Math.max(rn, gn, bn)\n const min = Math.min(rn, gn, bn)\n const l = (max + min) / 2\n if (max === min) return { h: 0, s: 0, l: l * 100 }\n\n const d = max - min\n const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n let h = 0\n switch (max) {\n case rn:\n h = (gn - bn) / d + (gn < bn ? 6 : 0)\n break\n case gn:\n h = (bn - rn) / d + 2\n break\n default:\n h = (rn - gn) / d + 4\n }\n return { h: (h / 6) * 360, s: s * 100, l: l * 100 }\n}\n\nexport function parseColor(input?: GooeyColor | string): GooeyColor {\n if (!input) return { ...DEFAULT_COLOR }\n if (typeof input !== \"string\") {\n return {\n h: clamp(input.h, 0, 360),\n s: clamp(input.s, 0, 100),\n l: clamp(input.l, 0, 100),\n a: clamp(input.a, 0, 1),\n }\n }\n\n const hsla =\n input.match(\n /hsla?\\(\\s*([\\d.]+)(?:deg)?[\\s,]+([\\d.]+)%[\\s,]+([\\d.]+)%(?:[\\s,/]+([\\d.]+)%?)?\\s*\\)/i\n ) ?? null\n if (hsla) {\n const aRaw = hsla[4] == null ? 1 : Number(hsla[4])\n return {\n h: clamp(Number(hsla[1]), 0, 360),\n s: clamp(Number(hsla[2]), 0, 100),\n l: clamp(Number(hsla[3]), 0, 100),\n a: clamp(aRaw > 1 ? aRaw / 100 : aRaw, 0, 1),\n }\n }\n\n const hex = input.trim().replace(/^#/, \"\").trim()\n if (isHexColor(hex)) {\n const full =\n hex.length === 3 || hex.length === 4\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex\n const r = Number.parseInt(full.slice(0, 2), 16)\n const g = Number.parseInt(full.slice(2, 4), 16)\n const b = Number.parseInt(full.slice(4, 6), 16)\n const a =\n full.length >= 8 ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1\n return { ...rgbToHsl(r, g, b), a: clamp(a, 0, 1) }\n }\n\n return { ...DEFAULT_COLOR }\n}\n\nexport type GooeyColorPickerProps = {\n value?: GooeyColor | string\n defaultValue?: GooeyColor | string\n onChange?: (color: GooeyColor, css: string) => void\n className?: string\n /** Accessible name for the trigger */\n label?: string\n}\n\n/** Minimal typing for the (still non-standard) EyeDropper API. */\ntype EyeDropperResult = { sRGBHex: string }\ntype EyeDropperConstructor = new () => {\n open: (options?: { signal?: AbortSignal }) => Promise\n}\n\nconst spring = {\n type: \"spring\" as const,\n stiffness: 420,\n damping: 32,\n mass: 0.85,\n}\n\nconst pathTransition = {\n duration: 0.32,\n ease: [0.22, 1, 0.36, 1] as const,\n}\n\nfunction useControllableColor(\n value: GooeyColorPickerProps[\"value\"],\n defaultValue: GooeyColorPickerProps[\"defaultValue\"],\n onChange: GooeyColorPickerProps[\"onChange\"]\n) {\n const [uncontrolled, setUncontrolled] = useState(() =>\n parseColor(defaultValue ?? value)\n )\n const isControlled = value !== undefined\n const color = isControlled ? parseColor(value) : uncontrolled\n\n const setColor = useCallback(\n (next: GooeyColor) => {\n if (!isControlled) setUncontrolled(next)\n onChange?.(next, toCss(next))\n },\n [isControlled, onChange]\n )\n\n return [color, setColor] as const\n}\n\nfunction startDrag(\n event: ReactPointerEvent,\n onMove: (clientX: number, clientY: number) => void\n) {\n event.preventDefault()\n const target = event.currentTarget\n const pointerId = event.pointerId\n target.setPointerCapture(pointerId)\n onMove(event.clientX, event.clientY)\n\n function move(e: PointerEvent) {\n if (e.pointerId === pointerId) onMove(e.clientX, e.clientY)\n }\n // A drag ends on release, on a cancelled touch, or when capture is lost,\n // which is also what happens if the picker unmounts mid-drag.\n function end(e: PointerEvent) {\n if (e.pointerId !== pointerId) return\n window.removeEventListener(\"pointermove\", move)\n window.removeEventListener(\"pointerup\", end)\n window.removeEventListener(\"pointercancel\", end)\n document.removeEventListener(\"lostpointercapture\", end, true)\n try {\n target.releasePointerCapture(pointerId)\n } catch {\n // ignore\n }\n }\n window.addEventListener(\"pointermove\", move)\n window.addEventListener(\"pointerup\", end)\n window.addEventListener(\"pointercancel\", end)\n document.addEventListener(\"lostpointercapture\", end, true)\n}\n\nconst noopSubscribe = () => () => {}\nconst hasEyeDropper = () => \"EyeDropper\" in window\nconst noEyeDropper = () => false\n\nfunction TriggerIcon({ open }: { open: boolean }) {\n const paths = open ? CLOSE_PATHS : PALETTE_PATHS\n\n return (\n \n \n \n {paths.map((d, i) => (\n \n ))}\n \n \n \n )\n}\n\n/** Minimal pipette / eyedropper glyph. */\nfunction EyeDropperIcon() {\n return (\n \n \n \n \n \n )\n}\n\nexport function GooeyColorPicker({\n value,\n defaultValue,\n onChange,\n className,\n label = \"Color picker\",\n}: GooeyColorPickerProps) {\n const rootRef = useRef(null)\n const wheelRef = useRef(null)\n const alphaRef = useRef(null)\n const [open, setOpen] = useState(false)\n const [color, setColor] = useControllableColor(value, defaultValue, onChange)\n // Drag and eyedropper handlers read the latest color without re-binding.\n const colorRef = useRef(color)\n useLayoutEffect(() => {\n colorRef.current = color\n })\n const css = toCss(color)\n const opaque = toCss({ ...color, a: 1 })\n const hex = toHex(color)\n const [hexDraft, setHexDraft] = useState(hex)\n const hexFocused = useRef(false)\n const supportsEyeDropper = useSyncExternalStore(\n noopSubscribe,\n hasEyeDropper,\n noEyeDropper\n )\n\n useEffect(() => {\n if (!hexFocused.current) setHexDraft(hex)\n }, [hex])\n\n // Commit a color from any picker surface (wheel, alpha, eyedropper) and keep\n // the code field in sync, even while the input is focused.\n const applyColor = useCallback(\n (next: GooeyColor) => {\n setColor(next)\n setHexDraft(toHex(next))\n },\n [setColor]\n )\n\n const pickWithEyeDropper = useCallback(async () => {\n const Ctor = (window as unknown as { EyeDropper?: EyeDropperConstructor })\n .EyeDropper\n if (!Ctor) return\n try {\n const { sRGBHex } = await new Ctor().open()\n applyColor({ ...parseColor(sRGBHex), a: colorRef.current.a })\n } catch {\n // user dismissed the eyedropper; no-op\n }\n }, [applyColor])\n\n function commitHex(raw: string) {\n const next = parseColor(raw.startsWith(\"#\") ? raw : `#${raw}`)\n const normalized = toHex(next)\n if (isHexColor(raw.trim())) {\n setColor({ ...next })\n setHexDraft(normalized)\n return\n }\n setHexDraft(hex)\n }\n\n function togglePicker() {\n setOpen((v) => !v)\n }\n\n function closePicker() {\n setOpen(false)\n }\n\n useEffect(() => {\n if (!open) return\n function onKeyDown(event: KeyboardEvent) {\n if (event.key === \"Escape\") closePicker()\n }\n window.addEventListener(\"keydown\", onKeyDown)\n return () => window.removeEventListener(\"keydown\", onKeyDown)\n }, [open])\n\n useEffect(() => {\n if (!open) return\n function onPointerDown(event: PointerEvent) {\n if (!rootRef.current?.contains(event.target as Node)) {\n closePicker()\n }\n }\n window.addEventListener(\"pointerdown\", onPointerDown)\n return () => window.removeEventListener(\"pointerdown\", onPointerDown)\n }, [open])\n\n const updateWheelFromPointer = useCallback(\n (clientX: number, clientY: number) => {\n const el = wheelRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const cx = rect.left + rect.width / 2\n const cy = rect.top + rect.height / 2\n const dx = clientX - cx\n const dy = clientY - cy\n const radius = rect.width / 2\n const dist = Math.min(Math.hypot(dx, dy) / radius, 1)\n const angle = (Math.atan2(dy, dx) * 180) / Math.PI\n const h = (angle + 90 + 360) % 360\n const s = clamp(dist * 100, 0, 100)\n applyColor({ ...colorRef.current, h, s, l: 55 })\n },\n [applyColor]\n )\n\n const updateAlphaFromPointer = useCallback(\n (clientX: number) => {\n const el = alphaRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const t = clamp((clientX - rect.left) / rect.width, 0, 1)\n applyColor({ ...colorRef.current, a: t })\n },\n [applyColor]\n )\n\n const wheelThumb = {\n x: 50 + Math.sin((color.h * Math.PI) / 180) * color.s * 0.42,\n y: 50 - Math.cos((color.h * Math.PI) / 180) * color.s * 0.42,\n }\n\n return (\n \n \n startDrag(event, updateWheelFromPointer)}\n onKeyDown={(event) => {\n if (event.key === \"ArrowLeft\") {\n event.preventDefault()\n applyColor({ ...color, h: (color.h + 350) % 360 })\n }\n if (event.key === \"ArrowRight\") {\n event.preventDefault()\n applyColor({ ...color, h: (color.h + 10) % 360 })\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault()\n applyColor({ ...color, s: clamp(color.s + 5, 0, 100) })\n }\n if (event.key === \"ArrowDown\") {\n event.preventDefault()\n applyColor({ ...color, s: clamp(color.s - 5, 0, 100) })\n }\n }}\n >\n \n \n\n startDrag(event, updateAlphaFromPointer)}\n onKeyDown={(event) => {\n if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") {\n event.preventDefault()\n applyColor({ ...color, a: clamp(color.a + 0.05, 0, 1) })\n }\n if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") {\n event.preventDefault()\n applyColor({ ...color, a: clamp(color.a - 0.05, 0, 1) })\n }\n }}\n >\n \n \n\n
    \n \n {\n hexFocused.current = true\n }}\n onChange={(event) => setHexDraft(event.target.value)}\n onBlur={() => {\n hexFocused.current = false\n commitHex(hexDraft)\n }}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.currentTarget.blur()\n }\n if (event.key === \"Escape\") {\n setHexDraft(hex)\n event.currentTarget.blur()\n }\n }}\n className=\"min-w-0 flex-1 bg-transparent font-mono text-sm text-popover-foreground outline-none placeholder:text-muted-foreground\"\n />\n {supportsEyeDropper ? (\n \n \n \n ) : null}\n
    \n \n\n \n \n \n \n )\n}\n\nexport default GooeyColorPicker\n", "type": "registry:ui", "target": "components/ui/gooey-color-picker.tsx" } diff --git a/public/r/iso-lattice-svelte.json b/public/r/iso-lattice-svelte.json new file mode 100644 index 0000000..05d28b3b --- /dev/null +++ b/public/r/iso-lattice-svelte.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "iso-lattice-svelte", + "title": "Iso Lattice", + "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", + "files": [ + { + "path": "registry/iso-lattice/iso-lattice.svelte", + "content": "\n\n\n\n\n \n\n", + "type": "registry:file", + "target": "src/lib/components/ui/iso-lattice.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/iso-lattice.json b/public/r/iso-lattice.json new file mode 100644 index 0000000..1e1dfae --- /dev/null +++ b/public/r/iso-lattice.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "iso-lattice", + "title": "Iso Lattice", + "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", + "files": [ + { + "path": "registry/iso-lattice/iso-lattice.tsx", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type IsoLatticeTheme = \"light\" | \"dark\" | \"auto\"\n\n/** The figures the lattice can light up, all drawn in isometric. */\nexport type IsoLatticeShape = \"cube\" | \"steps\" | \"pillars\" | \"coil\"\n\nexport type IsoLatticeOptions = {\n /**\n * Figures to cycle through, in order. One figure holds still.\n * Default `[\"cube\", \"steps\", \"pillars\", \"coil\"]`.\n */\n shapes?: IsoLatticeShape[]\n /** Seconds each figure holds before the next one builds. Default `4`. */\n interval?: number\n /** Tile color, any CSS hex. Omit to follow the theme. */\n color?: string\n /** Figure size as a share of the shorter side, 0.2–1. Default `0.6`. */\n size?: number\n /** Distance between lattice tiles in CSS px, 4–24. Default `8`. */\n spacing?: number\n /** Halo behind the figure, 0–1. `0` turns it off. Default `0.6`. */\n glow?: number\n /** Speed of the glint and the shimmer. Default `1`. */\n speed?: number\n /** A faint isometric floor grid under the figure. Default `true`. */\n floor?: boolean\n /** The pointer lights the lattice like a lamp. Default `true`. */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so light and dark swap with the site theme.\n */\n theme?: IsoLatticeTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type IsoLatticeInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Warm amber that glows on a dark page. */\nexport const DARK_COLOR = \"#FFB25B\"\n/** A burnt orange that holds its contrast on paper. */\nexport const LIGHT_COLOR = \"#C2560C\"\n\nexport const SHAPES: IsoLatticeShape[] = [\"cube\", \"steps\", \"pillars\", \"coil\"]\nexport const DEFAULT_INTERVAL = 4\nexport const DEFAULT_SIZE = 0.6\nexport const DEFAULT_SPACING = 8\nexport const DEFAULT_GLOW = 0.6\nexport const DEFAULT_SPEED = 1\n\n/** How long one figure takes to build over the last, in seconds. */\nconst MORPH_SECONDS = 1.6\n/** Each figure is drawn once into a square mask this many pixels wide. */\nconst MASK = 512\n/** Half the width of the mask in figure units; a figure's radius is 1. */\nconst EXTENT = 1.6\nconst COS30 = Math.cos(Math.PI / 6)\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform vec2 u_center;\nuniform float u_radius;\nuniform float u_spacing;\nuniform float u_pixel;\n// Red is the figure, green its blurred halo, blue the floor grid.\nuniform sampler2D u_maskA;\nuniform sampler2D u_maskB;\nuniform float u_progress;\nuniform float u_time;\nuniform float u_sweep;\nuniform float u_glow;\nuniform float u_floor;\nuniform vec2 u_mouse;\nuniform float u_pointer;\nuniform float u_dark;\nuniform vec3 u_color;\nuniform vec3 u_deep;\nuniform vec3 u_hot;\n\nconst float SQ3 = 1.7320508;\nconst float EXTENT = ${EXTENT.toFixed(2)};\n\n// Dave Hoskins' hash: no sin(), so it stays stable on mobile GPUs.\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nvec2 toUv(vec2 q) {\n return vec2(q.x, -q.y) / (2.0 * EXTENT) + 0.5;\n}\n\nvec2 sampleShape(vec2 q, float k) {\n vec2 uv = toUv(q);\n return mix(texture2D(u_maskA, uv).rg, texture2D(u_maskB, uv).rg, k);\n}\n\n// Whether a whole tile fits on the figure: its four tips, pulled in a touch,\n// must all land on it. A cell whose centre is inside but whose tips are not\n// would poke past the silhouette as a ragged half-lit column.\nfloat fits(sampler2D mask, vec2 q, vec2 tip) {\n float c = 1.0;\n c = min(c, texture2D(mask, toUv(q + vec2(tip.x, 0.0))).r);\n c = min(c, texture2D(mask, toUv(q - vec2(tip.x, 0.0))).r);\n c = min(c, texture2D(mask, toUv(q + vec2(0.0, tip.y))).r);\n c = min(c, texture2D(mask, toUv(q - vec2(0.0, tip.y))).r);\n return step(0.08, c);\n}\n\nvoid main() {\n vec2 frag = gl_FragCoord.xy;\n\n // Hex lattice in vertical columns, so its rows run at 30 degrees, the\n // same angles as an isometric edge. Each pixel finds its nearest cell.\n vec2 p = frag - 0.5 * u_resolution;\n vec2 period = vec2(SQ3, 1.0) * u_spacing;\n vec2 a = mod(p, period) - 0.5 * period;\n vec2 b = mod(p - 0.5 * period, period) - 0.5 * period;\n vec2 off = dot(a, a) < dot(b, b) ? a : b;\n vec2 cell = p - off;\n vec2 id = floor(cell / (0.5 * period) + 0.5);\n\n vec2 cellPx = cell + 0.5 * u_resolution;\n vec2 q = (cellPx - u_center) / u_radius;\n\n // The next figure builds from the floor up: each cell turns over when the\n // build front passes its height, give or take a little.\n float rise = clamp((q.y + 1.3) / 2.6, 0.0, 1.0);\n float delay = 0.62 * rise + 0.08 * hash(id);\n float k = smoothstep(0.0, 1.0, clamp((u_progress - delay) / 0.3, 0.0, 1.0));\n vec3 shapeA = texture2D(u_maskA, toUv(q)).rgb;\n vec3 shapeB = texture2D(u_maskB, toUv(q)).rgb;\n // The mask's soft outline would leave half-strength specks along every\n // edge; a cell is either on the figure or off it, and only when the whole\n // tile (at full size, so the call never depends on brightness) fits.\n vec2 tip = vec2(1.0, 1.0 / SQ3) * u_spacing * 0.866 * 0.84 * 0.85 / u_radius;\n shapeA.r *= smoothstep(0.12, 0.26, shapeA.r) * fits(u_maskA, q, tip);\n shapeB.r *= smoothstep(0.12, 0.26, shapeB.r) * fits(u_maskB, q, tip);\n float m = mix(shapeA.r, shapeB.r, k);\n // The build front burns brighter as it passes.\n float flare = 4.0 * k * (1.0 - k) * max(shapeA.r, shapeB.r);\n\n // A level band of light rises through the figure, then rests.\n float glint = exp(-pow((q.y - u_sweep) / 0.14, 2.0));\n float shimmer = 0.93 + 0.07 * sin(u_time * (1.1 + 1.6 * hash(id + 7.1)) + 6.2832 * hash(id + 3.7));\n\n // The pointer is a lamp: it raises the bare lattice and the figure under it.\n vec2 lamp = (cellPx - u_mouse) / (150.0 * u_pixel);\n float lit = exp(-dot(lamp, lamp)) * u_pointer;\n\n // Size follows only what the figure is, so tiles never pulse in and out;\n // the shimmer, the glint and the build front change light, not size.\n float floorLevel = shapeA.b * u_floor;\n float body = max(m, floorLevel) + lit * (0.14 + 0.3 * m);\n float level = max(m * shimmer * (1.0 + 0.6 * glint) + 0.7 * flare, floorLevel);\n level += lit * (0.14 + 0.45 * m);\n\n // Each cell is an isometric diamond; at full strength neighbours meet, so\n // lit faces read as solid tiles and dim ones break into a dotted grain.\n float fill = clamp(body, 0.0, 1.0);\n float w = u_spacing * 0.866 * mix(0.2, 0.84, sqrt(fill));\n float n = abs(off.x) + abs(off.y) * SQ3;\n float dotA = 1.0 - smoothstep(w - 0.9, w + 0.9, n);\n dotA *= smoothstep(0.015, 0.08, body);\n dotA *= mix(0.35, 1.0, smoothstep(0.0, 0.7, clamp(level, 0.0, 1.0)));\n vec3 dotC = mix(u_deep, u_color, smoothstep(0.0, 0.8, level));\n dotC = mix(dotC, u_hot, smoothstep(0.85, 1.6, level));\n\n // Halo: the blurred figure where this pixel sits, plus a broad wash.\n vec2 qp = (frag - u_center) / u_radius;\n float halo = sampleShape(qp, u_progress).g;\n float wash = exp(-dot(qp, qp) * 0.8);\n float glowA = u_glow * (halo * mix(0.18, 0.5, u_dark) + wash * mix(0.04, 0.16, u_dark));\n // Interleaved gradient noise, half a step, so the dark falloff never bands.\n float ign = fract(52.9829189 * fract(dot(frag, vec2(0.06711056, 0.00583715))));\n glowA = clamp(glowA + (ign - 0.5) / 255.0, 0.0, 1.0);\n\n // Dot over halo, premultiplied for a transparent canvas.\n float outA = dotA + glowA * (1.0 - dotA);\n vec3 outC = dotC * dotA + u_color * glowA * (1.0 - dotA);\n gl_FragColor = vec4(outC, outA);\n}\n`\n\ntype Ctx = CanvasRenderingContext2D\ntype Point = [number, number]\n\n/** Isometric projection: x runs down-right, z down-left, y straight up. */\nfunction iso(x: number, y: number, z: number): Point {\n return [(x - z) * COS30, y - (x + z) * 0.5]\n}\n\nfunction gray(level: number) {\n const v = Math.round(clamp(level, 0, 1) * 255)\n return `rgb(${v},${v},${v})`\n}\n\nfunction polyline(ctx: Ctx, points: Point[], close = false) {\n ctx.beginPath()\n points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)))\n if (close) ctx.closePath()\n}\n\nfunction stroke(ctx: Ctx, points: Point[], level: number, width: number) {\n polyline(ctx, points)\n ctx.strokeStyle = gray(level)\n ctx.lineWidth = width\n ctx.stroke()\n}\n\nfunction fill(ctx: Ctx, points: Point[], level: number) {\n polyline(ctx, points, true)\n ctx.fillStyle = gray(level)\n ctx.fill()\n}\n\n/** Every figure stands on this floor. */\nconst FLOOR = -0.4\n\n/**\n * A solid box lit from above: bright top, half-lit left, shaded right, with\n * the near edges picked out. Draw far boxes first; nearer ones cover them.\n */\nfunction box(ctx: Ctx, [x0, x1]: Point, [y0, y1]: Point, [z0, z1]: Point) {\n ctx.globalCompositeOperation = \"source-over\"\n // Butt caps: a round cap pokes past the corner as a stray tile.\n ctx.lineCap = \"butt\"\n ctx.lineJoin = \"round\"\n fill(\n ctx,\n [iso(x1, y1, z1), iso(x0, y1, z1), iso(x0, y0, z1), iso(x1, y0, z1)],\n 0.6\n )\n fill(\n ctx,\n [iso(x1, y1, z1), iso(x1, y1, z0), iso(x1, y0, z0), iso(x1, y0, z1)],\n 0.42\n )\n fill(\n ctx,\n [iso(x1, y1, z1), iso(x1, y1, z0), iso(x0, y1, z0), iso(x0, y1, z1)],\n 0.78\n )\n stroke(ctx, [iso(x0, y1, z1), iso(x1, y1, z1), iso(x1, y1, z0)], 1, 0.035)\n stroke(ctx, [iso(x1, y1, z1), iso(x1, y0, z1)], 0.7, 0.035)\n}\n\n/** One solid cube, a unit tall. */\nfunction drawCube(ctx: Ctx) {\n box(ctx, [-0.5, 0.5], [FLOOR, FLOOR + 1], [-0.5, 0.5])\n}\n\n/** Four steps climbing away from the viewer. */\nfunction drawSteps(ctx: Ctx) {\n for (let i = 3; i >= 0; i--) {\n const near = 0.5 - i * 0.25\n box(ctx, [-0.5, 0.5], [FLOOR, FLOOR + (i + 1) * 0.25], [near - 0.25, near])\n }\n}\n\n/** A three-by-three bar chart; heights are fixed so it reads as data. */\nfunction drawPillars(ctx: Ctx) {\n const heights = [\n [0.35, 0.6, 1],\n [0.55, 0.85, 0.45],\n [0.9, 0.3, 0.65],\n ]\n const cells: Array<[number, number, number]> = []\n for (let i = 0; i < 3; i++) {\n for (let j = 0; j < 3; j++) {\n cells.push([(i - 1) * 0.34, (j - 1) * 0.34, heights[i]![j]!])\n }\n }\n // Painter's order: the far corner of the grid first.\n cells.sort((a, b) => a[0] + a[1] - (b[0] + b[1]))\n const half = 0.13\n for (const [x, z, height] of cells) {\n box(\n ctx,\n [x - half, x + half],\n [FLOOR, FLOOR + height],\n [z - half, z + half]\n )\n }\n}\n\n/** A coil rising off the floor; the near side of each turn is the bright one. */\nfunction drawCoil(ctx: Ctx) {\n ctx.globalCompositeOperation = \"source-over\"\n ctx.lineCap = \"round\"\n const turns = 3.5\n const steps = 280\n const radius = 0.5\n const points: Array<[Point, number]> = []\n for (let i = 0; i <= steps; i++) {\n const t = (i / steps) * turns * Math.PI * 2\n const x = radius * Math.cos(t)\n const z = radius * Math.sin(t)\n const y = FLOOR + 0.06 + (i / steps) * 0.92\n points.push([iso(x, y, z), x + z])\n }\n polyline(ctx, ellipse(radius + 0.1, FLOOR), true)\n ctx.strokeStyle = gray(0.22)\n ctx.lineWidth = 0.04\n ctx.stroke()\n // Far half first, then the near half over it.\n for (const near of [false, true]) {\n for (let i = 1; i < points.length; i++) {\n const [a, depth] = points[i - 1]!\n const [b] = points[i]!\n if (depth > 0 !== near) continue\n stroke(ctx, [a, b], near ? 1 : 0.38, 0.085)\n }\n }\n}\n\n/** A flat circle on the floor plane, as a projected outline. */\nfunction ellipse(radius: number, y: number): Point[] {\n const out: Point[] = []\n for (let i = 0; i < 96; i++) {\n const t = (i / 96) * Math.PI * 2\n out.push(iso(radius * Math.cos(t), y, radius * Math.sin(t)))\n }\n return out\n}\n\n/** A faint floor tile with a coarse grid, the same under every figure. */\nfunction drawFloor(ctx: Ctx) {\n const r = 0.8\n ctx.globalCompositeOperation = \"source-over\"\n fill(\n ctx,\n [\n iso(r, FLOOR, r),\n iso(r, FLOOR, -r),\n iso(-r, FLOOR, -r),\n iso(-r, FLOOR, r),\n ],\n 0.05\n )\n for (let i = -2; i <= 2; i++) {\n const v = (i / 2) * r\n // Wide enough that every grid line lands on a full row of cells.\n stroke(ctx, [iso(v, FLOOR, -r), iso(v, FLOOR, r)], 0.15, 0.07)\n stroke(ctx, [iso(-r, FLOOR, v), iso(r, FLOOR, v)], 0.15, 0.07)\n }\n}\n\nconst DRAW: Record void> = {\n cube: drawCube,\n steps: drawSteps,\n pillars: drawPillars,\n coil: drawCoil,\n}\n\n/** Box-blurs by halving and growing back, where `ctx.filter` is missing. */\nfunction softBlur(source: HTMLCanvasElement, into: Ctx) {\n let current = source\n for (let size = MASK / 2; size >= MASK / 16; size /= 2) {\n const next = document.createElement(\"canvas\")\n next.width = next.height = size\n const ctx = next.getContext(\"2d\")!\n ctx.imageSmoothingQuality = \"high\"\n ctx.drawImage(current, 0, 0, size, size)\n current = next\n }\n into.imageSmoothingQuality = \"high\"\n into.drawImage(current, 0, 0, MASK, MASK)\n}\n\n// Figures don't depend on color or theme, so every instance shares them.\nconst maskCache = new Map()\n\n/** A black square canvas set up in figure units: y up, origin centered. */\nfunction figureCanvas(draw: (ctx: Ctx) => void) {\n const canvas = document.createElement(\"canvas\")\n canvas.width = canvas.height = MASK\n const ctx = canvas.getContext(\"2d\", { willReadFrequently: true })!\n ctx.fillStyle = \"#000\"\n ctx.fillRect(0, 0, MASK, MASK)\n const unit = MASK / (2 * EXTENT)\n ctx.setTransform(unit, 0, 0, -unit, MASK / 2, MASK / 2)\n draw(ctx)\n ctx.setTransform(1, 0, 0, 1, 0, 0)\n return { canvas, ctx }\n}\n\nlet floorPixels: Uint8ClampedArray | null = null\n\n/**\n * Draws a figure once: red is the crisp figure, green a soft halo, blue the\n * floor grid (the same in every mask, so it never changes in a build).\n */\nfunction renderMask(shape: IsoLatticeShape): Uint8Array {\n const cached = maskCache.get(shape)\n if (cached) return cached\n\n const { canvas: sharp, ctx } = figureCanvas(DRAW[shape])\n floorPixels ??= figureCanvas(drawFloor).ctx.getImageData(\n 0,\n 0,\n MASK,\n MASK\n ).data\n\n const soft = document.createElement(\"canvas\")\n soft.width = soft.height = MASK\n const sctx = soft.getContext(\"2d\", { willReadFrequently: true })!\n sctx.fillStyle = \"#000\"\n sctx.fillRect(0, 0, MASK, MASK)\n if (typeof sctx.filter === \"string\") {\n sctx.filter = `blur(${MASK / 36}px)`\n sctx.drawImage(sharp, 0, 0)\n } else {\n softBlur(sharp, sctx)\n }\n\n const a = ctx.getImageData(0, 0, MASK, MASK).data\n const b = sctx.getImageData(0, 0, MASK, MASK).data\n const out = new Uint8Array(MASK * MASK * 4)\n for (let i = 0; i < out.length; i += 4) {\n out[i] = a[i]!\n out[i + 1] = b[i]!\n out[i + 2] = floorPixels[i]!\n out[i + 3] = 255\n }\n maskCache.set(shape, out)\n return out\n}\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [1, 0.7, 0.36]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction mixRgb(\n a: [number, number, number],\n b: [number, number, number],\n t: number\n): [number, number, number] {\n return [\n a[0] + (b[0] - a[0]) * t,\n a[1] + (b[1] - a[1]) * t,\n a[2] + (b[2] - a[2]) * t,\n ]\n}\n\nfunction shapesOf(value: unknown): IsoLatticeShape[] {\n if (!Array.isArray(value)) return SHAPES\n const valid = value.filter((s): s is IsoLatticeShape => s in DRAW)\n return valid.length ? valid : SHAPES\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: IsoLatticeTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"IsoLattice: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * A lattice of isometric tiles that lights up solid figures (a cube, a\n * staircase, a bar chart, a coil) on a faint floor grid, and builds each\n * one over the last from the floor up. A level glint rises through the\n * figure, and the pointer lights the lattice like a lamp. Transparent, theme-aware; pauses\n * off-screen and in hidden tabs; holds the first figure still under\n * `prefers-reduced-motion`. Recovers from a lost WebGL context.\n */\nexport function createIsoLattice(\n canvas: HTMLCanvasElement,\n initial: IsoLatticeOptions = {}\n): IsoLatticeInstance | null {\n let options: IsoLatticeOptions = {\n floor: true,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0, y: 0 }\n const target = { x: 0, y: 0 }\n // The lamp eases in and out, so leaving the canvas never snaps it off.\n let lamp = 0\n let lampTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n type Loc = WebGLUniformLocation | null\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let textures = new Map()\n const u: Record = {}\n\n const texture = (shape: IsoLatticeShape) => {\n const existing = textures.get(shape)\n if (existing) return existing\n const tex = gl.createTexture()!\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n MASK,\n MASK,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n renderMask(shape)\n )\n textures.set(shape, tex)\n return tex\n }\n\n // Builds every GL resource. Runs at startup and again when the browser\n // restores a lost context, since the old objects die with it.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"IsoLattice: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n gl.viewport(0, 0, canvas.width, canvas.height)\n\n const linked = program\n for (const name of [\n \"resolution\",\n \"center\",\n \"radius\",\n \"spacing\",\n \"pixel\",\n \"maskA\",\n \"maskB\",\n \"progress\",\n \"time\",\n \"sweep\",\n \"glow\",\n \"floor\",\n \"mouse\",\n \"pointer\",\n \"dark\",\n \"color\",\n \"deep\",\n \"hot\",\n ]) {\n u[name] = gl.getUniformLocation(linked, `u_${name}`)\n }\n gl.uniform1i(u.maskA!, 0)\n gl.uniform1i(u.maskB!, 1)\n textures = new Map()\n return true\n }\n if (!initGl()) return null\n\n let pixel = 1\n /**\n * Sizes the canvas to its parent; true when the backing store changed.\n * Assigning the same size still clears the canvas, so it only assigns on\n * a real change.\n */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n pixel = Math.min(window.devicePixelRatio || 1, 2)\n const width = Math.max(1, Math.floor(w * pixel))\n const height = Math.max(1, Math.floor(h * pixel))\n if (canvas.width === width && canvas.height === height) return false\n canvas.width = width\n canvas.height = height\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n return true\n }\n\n let colorKey = \"\"\n const syncColor = () => {\n const hex = options.color || (dark ? DARK_COLOR : LIGHT_COLOR)\n const key = `${hex}|${dark}`\n if (key === colorKey) return\n colorKey = key\n const base = hexToRgb(hex)\n const white: [number, number, number] = [1, 1, 1]\n const black: [number, number, number] = [0, 0, 0]\n // Dim dots sink toward the page; hot dots move away from it.\n const deep = mixRgb(base, dark ? black : white, 0.55)\n const hot = mixRgb(base, dark ? white : black, dark ? 0.62 : 0.5)\n gl.uniform3fv(u.color!, base)\n gl.uniform3fv(u.deep!, deep)\n gl.uniform3fv(u.hot!, hot)\n gl.uniform1f(u.dark!, dark ? 1 : 0)\n }\n\n // Which figure is showing, and how far the morph to the next one has got.\n let shapes = shapesOf(options.shapes)\n let index = 0\n let held = 0\n let progress = 0\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let clock = 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n /** Draws in place now, without advancing; the tick carries on if live. */\n function redraw() {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n const advance = (dt: number) => {\n if (shapes.length < 2) {\n progress = 0\n return\n }\n if (\n progress > 0 ||\n held >= numberOr(options.interval, DEFAULT_INTERVAL, 0.5, 60)\n ) {\n progress += dt / MORPH_SECONDS\n if (progress >= 1) {\n index = (index + 1) % shapes.length\n progress = 0\n held = 0\n }\n } else {\n held += dt\n }\n }\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.1, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n if (live) {\n clock += dt * speed\n advance(dt)\n }\n // Under reduced motion the first figure holds, with no lamp.\n const still = mqReduce.matches\n if (still) {\n index = 0\n progress = 0\n }\n\n const ease = 1 - Math.exp(-dt * 6)\n mouse.x += (target.x - mouse.x) * ease\n mouse.y += (target.y - mouse.y) * ease\n lamp += (lampTarget - lamp) * (1 - Math.exp(-dt * 3))\n\n syncColor()\n const w = canvas.width\n const h = canvas.height\n const radius =\n (numberOr(options.size, DEFAULT_SIZE, 0.2, 1) * Math.min(w, h)) / 2\n const pointer = options.interactive === false || still ? 0 : lamp\n // The figure stays put: any drift would slide it across the lattice and\n // make the tiles along its edges blink.\n const cx = w / 2\n const cy = h / 2\n\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, texture(shapes[index % shapes.length]!))\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, texture(shapes[(index + 1) % shapes.length]!))\n\n gl.uniform2f(u.resolution!, w, h)\n gl.uniform2f(u.center!, cx, cy)\n gl.uniform1f(u.radius!, radius)\n gl.uniform1f(\n u.spacing!,\n numberOr(options.spacing, DEFAULT_SPACING, 4, 24) * pixel\n )\n gl.uniform1f(u.pixel!, pixel)\n gl.uniform1f(u.progress!, progress)\n gl.uniform1f(u.time!, clock)\n // The glint rises through the figure, then rests above it a while.\n gl.uniform1f(u.sweep!, still ? -9 : -1.6 + 4.2 * ((clock / 4.2) % 1))\n gl.uniform1f(u.glow!, numberOr(options.glow, DEFAULT_GLOW, 0, 1))\n gl.uniform1f(u.floor!, options.floor === false ? 0 : 1)\n gl.uniform2f(u.mouse!, mouse.x, mouse.y)\n gl.uniform1f(u.pointer!, pointer)\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n const settling = Math.abs(lampTarget - lamp) > 0.002\n if (live || (settling && !still)) raf = requestAnimationFrame(tick)\n }\n\n // A GPU reset, a driver update or too many live contexts can drop ours.\n // preventDefault asks the browser to restore it; until then gl is off limits.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n vs = fs = program = buf = null\n textures = new Map()\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n // Uniforms reset with the context, so the colors go up again.\n colorKey = \"\"\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n // Resizing cleared the canvas; draw now, or it shows blank for a frame.\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n if (inside) {\n // The first touch puts the lamp where the pointer is, not at a corner.\n if (lamp < 0.01) {\n mouse.x = x * canvas.width\n mouse.y = y * canvas.height\n }\n target.x = x * canvas.width\n target.y = y * canvas.height\n }\n const next = inside ? 1 : 0\n if (next !== lampTarget) {\n lampTarget = next\n wake()\n }\n }\n const onLeave = () => {\n lampTarget = 0\n wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n document.documentElement.addEventListener(\"pointerleave\", onLeave)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n const nextShapes = shapesOf(options.shapes)\n if (nextShapes.join() !== shapes.join()) {\n shapes = nextShapes\n index = 0\n held = 0\n progress = 0\n }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n document.documentElement.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n for (const tex of textures.values()) gl.deleteTexture(tex)\n textures.clear()\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type IsoLatticeProps = Omit & {\n className?: string\n}\n\n/**\n * A lattice of glowing isometric tiles that builds solid figures, one over\n * the last, from the floor up. Transparent; theme-aware.\n */\nexport function IsoLattice({\n className,\n shapes = SHAPES,\n interval = DEFAULT_INTERVAL,\n color,\n size = DEFAULT_SIZE,\n spacing = DEFAULT_SPACING,\n glow = DEFAULT_GLOW,\n speed = DEFAULT_SPEED,\n floor = true,\n interactive = true,\n theme = \"auto\",\n}: IsoLatticeProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createIsoLattice(canvas, {\n shapes,\n interval,\n color,\n size,\n spacing,\n glow,\n speed,\n floor,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n shapes,\n interval,\n color,\n size,\n spacing,\n glow,\n speed,\n floor,\n interactive,\n theme,\n })\n }, [\n shapes,\n interval,\n color,\n size,\n spacing,\n glow,\n speed,\n floor,\n interactive,\n theme,\n ])\n\n return (\n \n \n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/iso-lattice.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/live-orb-svelte.json b/public/r/live-orb-svelte.json index 094df92..653630b 100644 --- a/public/r/live-orb-svelte.json +++ b/public/r/live-orb-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/live-orb/live-orb.svelte", - "content": "\n\n\n\n\n {#if !hasGl}\n \n \n \n \n {/if}\n \n\n", + "content": "\n\n\n\n\n {#if !hasGl}\n \n \n \n \n {/if}\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/live-orb.svelte" } diff --git a/public/r/live-orb.json b/public/r/live-orb.json index a5632f5..64da277 100644 --- a/public/r/live-orb.json +++ b/public/r/live-orb.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/live-orb/live-orb.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LiveOrbVariant = \"white\" | \"black\" | \"webgl\" | \"custom\"\n\nexport type LiveOrbOptions = {\n /** Material preset. Default `\"white\"`. */\n variant?: LiveOrbVariant\n /** Body hex, used when `variant=\"custom\"`. */\n color?: string\n /** Eye hex, used when `variant=\"custom\"`. */\n eyeColor?: string\n /** Unlit wash stops, used when `variant=\"webgl\"`. */\n colors?: string[]\n /**\n * Eyes follow the pointer. Default `true`. Without it, or once the pointer\n * rests for a few seconds, they glance around on their own.\n */\n interactive?: boolean\n /** Occasional blink. Default `true`. */\n blink?: boolean\n /** Fires when WebGL is ready (`true`) or torn down (`false`). */\n onHasGl?: (ok: boolean) => void\n}\n\nexport type LiveOrbInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const WHITE = { color: \"#F4F4F5\", eyeColor: \"#09090B\" } as const\nexport const BLACK = { color: \"#18181B\", eyeColor: \"#F4F4F5\" } as const\nexport const CUSTOM_DEFAULT = { color: \"#7C5CFF\", eyeColor: \"#FAFAFA\" } as const\n/** Violet / foam / dust-rose: stock unlit wash. */\nexport const WEBGL_COLORS = [\"#7C6AF7\", \"#7DD3C7\", \"#E8B4D4\"]\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform vec2 u_look;\nuniform float u_blink;\nuniform float u_mode;\nuniform vec3 u_body;\nuniform vec3 u_eye;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 5; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec3 orient(vec3 p, vec2 look) {\n float yaw = look.x * 0.92;\n float pitch = -look.y * 0.78;\n float cy = cos(yaw);\n float sy = sin(yaw);\n float cp = cos(pitch);\n float sp = sin(pitch);\n vec3 q = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);\n return vec3(q.x * cy + q.z * sy, q.y, -q.x * sy + q.z * cy);\n}\n\nfloat eyeMask(vec3 n, vec3 e, vec3 right, vec3 up, float halfH, float rad) {\n float facing = dot(n, e);\n float x = dot(n, right) - dot(e, right);\n float y = dot(n, up) - dot(e, up);\n y -= clamp(y, -halfH, halfH);\n float d = length(vec2(x, y)) - rad;\n float fill = 1.0 - smoothstep(-0.01, 0.01, d);\n return fill * smoothstep(0.12, 0.32, facing);\n}\n\nvec3 wash(vec3 n, float t) {\n vec2 q = n.xy * 2.2 + n.z * 0.85;\n vec2 w1 = vec2(\n fbm(q * 1.4 + vec2(t * 0.22, t * 0.18)),\n fbm(q * 1.4 + vec2(-t * 0.16, t * 0.24) + 4.1)\n );\n q += (w1 - 0.5) * 0.72;\n float f = fbm(q * 2.1 + vec2(0.0, t * 0.12));\n float g = fbm(q * 4.6 - vec2(t * 0.2, 0.0));\n vec3 col = mix(u_c1, u_c2, smoothstep(0.28, 0.72, f));\n return mix(col, u_c3, pow(smoothstep(0.42, 0.9, g), 1.4));\n}\n\nvoid main() {\n vec2 uv = (gl_FragCoord.xy / u_resolution.xy) * 2.0 - 1.0;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n uv.x *= aspect;\n\n float radius = 0.94;\n vec2 p = uv / radius;\n float r2 = dot(p, p);\n float edge = 1.0 - smoothstep(0.985, 1.012, sqrt(max(r2, 0.0)));\n if (edge <= 0.001) {\n gl_FragColor = vec4(0.0);\n return;\n }\n\n float z = sqrt(max(1.0 - r2, 0.0));\n vec3 n = normalize(vec3(p, z));\n\n vec2 look = u_look;\n float lm = length(look);\n if (lm > 1.0) look /= lm;\n\n vec3 right = orient(vec3(1.0, 0.0, 0.0), look);\n vec3 up = orient(vec3(0.0, 1.0, 0.0), look);\n vec3 eL = orient(normalize(vec3(-0.32, 0.08, 1.0)), look);\n vec3 eR = orient(normalize(vec3(0.32, 0.08, 1.0)), look);\n\n float halfH = mix(0.128, 0.012, u_blink);\n float rad = mix(0.054, 0.062, u_blink);\n float eyes = max(\n eyeMask(n, eL, right, up, halfH, rad),\n eyeMask(n, eR, right, up, halfH, rad)\n );\n\n vec3 body = u_mode > 0.5 ? wash(n, u_time * u_speed) : u_body;\n vec3 col = mix(body, u_eye, clamp(eyes, 0.0, 1.0));\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), edge);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.96, 0.96, 0.96]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nexport function resolveVariant(\n variant: LiveOrbVariant,\n color?: string,\n eyeColor?: string,\n colors?: string[]\n) {\n let body: string = WHITE.color\n let eye: string = WHITE.eyeColor\n let mode = 0\n let palette: string[] = WEBGL_COLORS\n\n if (variant === \"black\") {\n body = BLACK.color\n eye = BLACK.eyeColor\n } else if (variant === \"webgl\") {\n palette = colors && colors.length > 0 ? colors : WEBGL_COLORS\n body = palette[1] ?? WEBGL_COLORS[1]!\n eye = \"#0C0C10\"\n mode = 1\n } else if (variant === \"custom\") {\n body = color ?? CUSTOM_DEFAULT.color\n eye = eyeColor ?? CUSTOM_DEFAULT.eyeColor\n }\n\n return { body, eye, mode, palette }\n}\n\nexport function fallbackFaceStyle(\n resolved: ReturnType\n): {\n backgroundColor?: string\n backgroundImage?: string\n} {\n if (resolved.mode === 1) {\n const a = resolved.palette[0] ?? WEBGL_COLORS[0]\n const b = resolved.palette[1] ?? WEBGL_COLORS[1]\n const c = resolved.palette[2] ?? WEBGL_COLORS[2]\n return {\n backgroundImage: `linear-gradient(135deg, ${a}, ${b}, ${c})`,\n }\n }\n return { backgroundColor: resolved.body }\n}\n\n/** How long the pointer rests before the eyes start looking around. */\nconst IDLE_MS = 3000\nconst REST_LOOK = { x: 0, y: 0.08 }\n\n/**\n * Evenly lit sphere with two capsule eyes that follow the pointer and look\n * around when it rests.\n * The orb stays put; only the gaze moves. Frames are drawn only while the\n * canvas is on screen and the tab is visible. Under reduced motion it holds a\n * still, settled frame and redraws it when its inputs change. It recovers\n * from a lost WebGL context.\n */\nexport function createLiveOrb(\n canvas: HTMLCanvasElement,\n initial: LiveOrbOptions = {}\n): LiveOrbInstance | null {\n let options: Required<\n Pick\n > &\n LiveOrbOptions = {\n variant: \"white\",\n interactive: true,\n blink: true,\n ...initial,\n }\n\n const look = { x: 0, y: 0.08 }\n const targetLook = { x: 0, y: 0.08 }\n // Where the eyes drift while nobody is steering them.\n const glance = { x: 0, y: 0.08 }\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n premultipliedAlpha: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: WebGLUniformLocation | null = null\n let uTime: WebGLUniformLocation | null = null\n let uSpeed: WebGLUniformLocation | null = null\n let uLook: WebGLUniformLocation | null = null\n let uBlink: WebGLUniformLocation | null = null\n let uMode: WebGLUniformLocation | null = null\n let uBody: WebGLUniformLocation | null = null\n let uEye: WebGLUniformLocation | null = null\n let uC1: WebGLUniformLocation | null = null\n let uC2: WebGLUniformLocation | null = null\n let uC3: WebGLUniformLocation | null = null\n\n // Builds every GL resource. Runs at startup and again after a context restore.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) return false\n\n const prog = gl.createProgram()\n if (!prog) return false\n program = prog\n gl.attachShader(prog, vs)\n gl.attachShader(prog, fs)\n gl.linkProgram(prog)\n if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: program failed to link\\n\",\n gl.getProgramInfoLog(prog)\n )\n }\n return false\n }\n gl.useProgram(prog)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(prog, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n uResolution = gl.getUniformLocation(prog, \"u_resolution\")\n uTime = gl.getUniformLocation(prog, \"u_time\")\n uSpeed = gl.getUniformLocation(prog, \"u_speed\")\n uLook = gl.getUniformLocation(prog, \"u_look\")\n uBlink = gl.getUniformLocation(prog, \"u_blink\")\n uMode = gl.getUniformLocation(prog, \"u_mode\")\n uBody = gl.getUniformLocation(prog, \"u_body\")\n uEye = gl.getUniformLocation(prog, \"u_eye\")\n uC1 = gl.getUniformLocation(prog, \"u_c1\")\n uC2 = gl.getUniformLocation(prog, \"u_c2\")\n uC3 = gl.getUniformLocation(prog, \"u_c3\")\n\n gl.enable(gl.BLEND)\n gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)\n gl.viewport(0, 0, canvas.width, canvas.height)\n return true\n }\n\n if (!initGl()) return null\n options.onHasGl?.(true)\n\n let raf = 0\n let destroyed = false\n // Set while the GPU context is gone; nothing touches gl until it returns.\n let lost = false\n let onScreen = true\n let last = 0\n // Milliseconds of motion so far; only advances while the orb is moving.\n let clock = 0\n let nextBlink = 1800 + Math.random() * 2400\n let blinkAt = -10_000\n let nextGlance = 600 + Math.random() * 900\n // When the pointer last moved, from performance.now().\n let pointerAt = -Infinity\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n // Resizing clears the canvas, so a still frame needs a redraw.\n wake()\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2)\n const dy = (rect.top + rect.height / 2 - e.clientY) / (rect.height / 2)\n targetLook.x = Math.min(1, Math.max(-1, dx))\n targetLook.y = Math.min(1, Math.max(-1, dy))\n pointerAt = performance.now()\n // While moving the loop is already queued; a still frame follows the gaze.\n if (onScreen) wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(50, Math.max(0, now - last))\n last = now\n const live = moving()\n if (live) clock += dt\n const reduce = mqReduce.matches\n\n const resolved = resolveVariant(\n options.variant,\n options.color,\n options.eyeColor,\n options.colors\n )\n const time = clock / 1000\n\n // The pointer steers until it rests; then the eyes look around, now\n // and then back at you. Reduced motion holds a still, centred gaze.\n const steering = options.interactive && now - pointerAt < IDLE_MS\n if (!steering && live && clock >= nextGlance) {\n const back = Math.random() < 0.3\n glance.x = back ? 0 : (Math.random() * 2 - 1) * 0.8\n glance.y = back ? 0.08 : (Math.random() * 2 - 1) * 0.5\n nextGlance = clock + 900 + Math.random() * 2100\n }\n const goal = steering ? targetLook : reduce ? REST_LOOK : glance\n // A still frame skips the easing and shows where the gaze settles.\n const ease = live ? (steering ? 0.16 : 0.1) : 1\n look.x += (goal.x - look.x) * ease\n look.y += (goal.y - look.y) * ease\n\n if (!reduce && options.blink && clock >= nextBlink) {\n blinkAt = clock\n nextBlink = clock + 2200 + Math.random() * 3800\n }\n const bt = (clock - blinkAt) / 1000\n let b = 0\n if (!reduce && options.blink) {\n if (bt < 0.055) b = bt / 0.055\n else if (bt < 0.1) b = 1\n else if (bt < 0.18) b = 1 - (bt - 0.1) / 0.08\n }\n const body = hexToRgb(resolved.body)\n const eye = hexToRgb(resolved.eye)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(\n resolved.palette[i % resolved.palette.length] ?? WEBGL_COLORS[i]!\n )\n )\n\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, reduce ? 0 : time)\n gl.uniform1f(uSpeed, reduce ? 0 : 0.55)\n gl.uniform2f(uLook, look.x, look.y)\n gl.uniform1f(uBlink, b)\n gl.uniform1f(uMode, resolved.mode)\n gl.uniform3f(uBody, body[0], body[1], body[2])\n gl.uniform3f(uEye, eye[0], eye[1], eye[2])\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n if (live && !destroyed) raf = requestAnimationFrame(tick)\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n // Without preventDefault the browser never hands the context back.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n // A lost canvas paints a broken-image icon over the CSS fallback.\n canvas.style.visibility = \"hidden\"\n options.onHasGl?.(false)\n }\n const onRestored = () => {\n if (destroyed) return\n // A failed rebuild leaves the orb on its CSS fallback.\n if (!initGl()) return\n lost = false\n canvas.style.visibility = \"\"\n options.onHasGl?.(true)\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n if (lost) canvas.style.visibility = \"\"\n options.onHasGl?.(false)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type LiveOrbProps = Omit & {\n className?: string\n /** Edge length in CSS pixels. Default `280`. */\n size?: number\n}\n\n/**\n * Evenly lit sphere with two capsule eyes that follow the pointer.\n * The orb stays put; only the gaze moves.\n */\nexport function LiveOrb({\n className,\n size = 280,\n variant = \"white\",\n color,\n eyeColor,\n colors,\n interactive = true,\n blink = true,\n}: LiveOrbProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hasGl, setHasGl] = useState(false)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLiveOrb(canvas, {\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n onHasGl: setHasGl,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n onHasGl: setHasGl,\n })\n }, [variant, color, eyeColor, colors, interactive, blink])\n\n const resolved = resolveVariant(variant, color, eyeColor, colors)\n\n return (\n \n {!hasGl ? (\n \n \n \n \n ) : null}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LiveOrbVariant = \"white\" | \"black\" | \"webgl\" | \"custom\"\n\nexport type LiveOrbTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LiveOrbOptions = {\n /**\n * Material preset. Omit it and the orb follows the theme: a white orb\n * on dark pages, a black one on light pages.\n */\n variant?: LiveOrbVariant\n /**\n * Which theme the default variant follows. `auto` (the default) reads\n * `html.dark` / `html.light`, then `data-theme`, then\n * `prefers-color-scheme`, and keeps up with changes.\n */\n theme?: LiveOrbTheme\n /** Body hex, used when `variant=\"custom\"`. */\n color?: string\n /** Eye hex, used when `variant=\"custom\"`. */\n eyeColor?: string\n /** Unlit wash stops, used when `variant=\"webgl\"`. */\n colors?: string[]\n /**\n * Eyes follow the pointer. Default `true`. Without it, or once the pointer\n * rests for a few seconds, they glance around on their own.\n */\n interactive?: boolean\n /** Occasional blink. Default `true`. */\n blink?: boolean\n /** Fires when WebGL is ready (`true`) or torn down (`false`). */\n onHasGl?: (ok: boolean) => void\n}\n\nexport type LiveOrbInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const WHITE = { color: \"#F4F4F5\", eyeColor: \"#09090B\" } as const\nexport const BLACK = { color: \"#18181B\", eyeColor: \"#F4F4F5\" } as const\nexport const CUSTOM_DEFAULT = { color: \"#7C5CFF\", eyeColor: \"#FAFAFA\" } as const\n/** Violet / foam / dust-rose: stock unlit wash. */\nexport const WEBGL_COLORS = [\"#7C6AF7\", \"#7DD3C7\", \"#E8B4D4\"]\n\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LiveOrbTheme = \"auto\"): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\n/**\n * Calls `onChange` whenever the page theme may have changed (the `html`\n * class, `data-theme` or `style`, or the system preference). Returns the\n * unsubscribe.\n */\nexport function watchTheme(onChange: () => void): () => void {\n if (typeof document === \"undefined\") return () => {}\n const mo = new MutationObserver(onChange)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", onChange)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", onChange)\n }\n}\n\n/** The material an omitted `variant` resolves to. */\nexport function defaultVariant(dark: boolean): LiveOrbVariant {\n return dark ? \"white\" : \"black\"\n}\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform vec2 u_look;\nuniform float u_blink;\nuniform float u_mode;\nuniform vec3 u_body;\nuniform vec3 u_eye;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 5; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec3 orient(vec3 p, vec2 look) {\n float yaw = look.x * 0.92;\n float pitch = -look.y * 0.78;\n float cy = cos(yaw);\n float sy = sin(yaw);\n float cp = cos(pitch);\n float sp = sin(pitch);\n vec3 q = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);\n return vec3(q.x * cy + q.z * sy, q.y, -q.x * sy + q.z * cy);\n}\n\nfloat eyeMask(vec3 n, vec3 e, vec3 right, vec3 up, float halfH, float rad) {\n float facing = dot(n, e);\n float x = dot(n, right) - dot(e, right);\n float y = dot(n, up) - dot(e, up);\n y -= clamp(y, -halfH, halfH);\n float d = length(vec2(x, y)) - rad;\n float fill = 1.0 - smoothstep(-0.01, 0.01, d);\n return fill * smoothstep(0.12, 0.32, facing);\n}\n\nvec3 wash(vec3 n, float t) {\n vec2 q = n.xy * 2.2 + n.z * 0.85;\n vec2 w1 = vec2(\n fbm(q * 1.4 + vec2(t * 0.22, t * 0.18)),\n fbm(q * 1.4 + vec2(-t * 0.16, t * 0.24) + 4.1)\n );\n q += (w1 - 0.5) * 0.72;\n float f = fbm(q * 2.1 + vec2(0.0, t * 0.12));\n float g = fbm(q * 4.6 - vec2(t * 0.2, 0.0));\n vec3 col = mix(u_c1, u_c2, smoothstep(0.28, 0.72, f));\n return mix(col, u_c3, pow(smoothstep(0.42, 0.9, g), 1.4));\n}\n\nvoid main() {\n vec2 uv = (gl_FragCoord.xy / u_resolution.xy) * 2.0 - 1.0;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n uv.x *= aspect;\n\n float radius = 0.94;\n vec2 p = uv / radius;\n float r2 = dot(p, p);\n float edge = 1.0 - smoothstep(0.985, 1.012, sqrt(max(r2, 0.0)));\n if (edge <= 0.001) {\n gl_FragColor = vec4(0.0);\n return;\n }\n\n float z = sqrt(max(1.0 - r2, 0.0));\n vec3 n = normalize(vec3(p, z));\n\n vec2 look = u_look;\n float lm = length(look);\n if (lm > 1.0) look /= lm;\n\n vec3 right = orient(vec3(1.0, 0.0, 0.0), look);\n vec3 up = orient(vec3(0.0, 1.0, 0.0), look);\n vec3 eL = orient(normalize(vec3(-0.32, 0.08, 1.0)), look);\n vec3 eR = orient(normalize(vec3(0.32, 0.08, 1.0)), look);\n\n float halfH = mix(0.128, 0.012, u_blink);\n float rad = mix(0.054, 0.062, u_blink);\n float eyes = max(\n eyeMask(n, eL, right, up, halfH, rad),\n eyeMask(n, eR, right, up, halfH, rad)\n );\n\n vec3 body = u_mode > 0.5 ? wash(n, u_time * u_speed) : u_body;\n vec3 col = mix(body, u_eye, clamp(eyes, 0.0, 1.0));\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), edge);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.96, 0.96, 0.96]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nexport function resolveVariant(\n variant: LiveOrbVariant | undefined,\n color?: string,\n eyeColor?: string,\n colors?: string[],\n dark = false\n) {\n let body: string = WHITE.color\n let eye: string = WHITE.eyeColor\n let mode = 0\n let palette: string[] = WEBGL_COLORS\n const preset = variant ?? defaultVariant(dark)\n\n if (preset === \"black\") {\n body = BLACK.color\n eye = BLACK.eyeColor\n } else if (preset === \"webgl\") {\n palette = colors && colors.length > 0 ? colors : WEBGL_COLORS\n body = palette[1] ?? WEBGL_COLORS[1]!\n eye = \"#0C0C10\"\n mode = 1\n } else if (preset === \"custom\") {\n body = color ?? CUSTOM_DEFAULT.color\n eye = eyeColor ?? CUSTOM_DEFAULT.eyeColor\n }\n\n return { body, eye, mode, palette }\n}\n\nexport function fallbackFaceStyle(\n resolved: ReturnType\n): {\n backgroundColor?: string\n backgroundImage?: string\n} {\n if (resolved.mode === 1) {\n const a = resolved.palette[0] ?? WEBGL_COLORS[0]\n const b = resolved.palette[1] ?? WEBGL_COLORS[1]\n const c = resolved.palette[2] ?? WEBGL_COLORS[2]\n return {\n backgroundImage: `linear-gradient(135deg, ${a}, ${b}, ${c})`,\n }\n }\n return { backgroundColor: resolved.body }\n}\n\n/** How long the pointer rests before the eyes start looking around. */\nconst IDLE_MS = 3000\nconst REST_LOOK = { x: 0, y: 0.08 }\n\n/**\n * Evenly lit sphere with two capsule eyes that follow the pointer and look\n * around when it rests.\n * The orb stays put; only the gaze moves. Frames are drawn only while the\n * canvas is on screen and the tab is visible. Under reduced motion it holds a\n * still, settled frame and redraws it when its inputs change. It recovers\n * from a lost WebGL context.\n */\nexport function createLiveOrb(\n canvas: HTMLCanvasElement,\n initial: LiveOrbOptions = {}\n): LiveOrbInstance | null {\n let options: Required> &\n LiveOrbOptions = {\n interactive: true,\n blink: true,\n ...initial,\n }\n let dark = resolveDark(options.theme)\n\n const look = { x: 0, y: 0.08 }\n const targetLook = { x: 0, y: 0.08 }\n // Where the eyes drift while nobody is steering them.\n const glance = { x: 0, y: 0.08 }\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n premultipliedAlpha: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: WebGLUniformLocation | null = null\n let uTime: WebGLUniformLocation | null = null\n let uSpeed: WebGLUniformLocation | null = null\n let uLook: WebGLUniformLocation | null = null\n let uBlink: WebGLUniformLocation | null = null\n let uMode: WebGLUniformLocation | null = null\n let uBody: WebGLUniformLocation | null = null\n let uEye: WebGLUniformLocation | null = null\n let uC1: WebGLUniformLocation | null = null\n let uC2: WebGLUniformLocation | null = null\n let uC3: WebGLUniformLocation | null = null\n\n // Builds every GL resource. Runs at startup and again after a context restore.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) return false\n\n const prog = gl.createProgram()\n if (!prog) return false\n program = prog\n gl.attachShader(prog, vs)\n gl.attachShader(prog, fs)\n gl.linkProgram(prog)\n if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: program failed to link\\n\",\n gl.getProgramInfoLog(prog)\n )\n }\n return false\n }\n gl.useProgram(prog)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(prog, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n uResolution = gl.getUniformLocation(prog, \"u_resolution\")\n uTime = gl.getUniformLocation(prog, \"u_time\")\n uSpeed = gl.getUniformLocation(prog, \"u_speed\")\n uLook = gl.getUniformLocation(prog, \"u_look\")\n uBlink = gl.getUniformLocation(prog, \"u_blink\")\n uMode = gl.getUniformLocation(prog, \"u_mode\")\n uBody = gl.getUniformLocation(prog, \"u_body\")\n uEye = gl.getUniformLocation(prog, \"u_eye\")\n uC1 = gl.getUniformLocation(prog, \"u_c1\")\n uC2 = gl.getUniformLocation(prog, \"u_c2\")\n uC3 = gl.getUniformLocation(prog, \"u_c3\")\n\n gl.enable(gl.BLEND)\n gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)\n gl.viewport(0, 0, canvas.width, canvas.height)\n return true\n }\n\n if (!initGl()) return null\n options.onHasGl?.(true)\n\n let raf = 0\n let destroyed = false\n // Set while the GPU context is gone; nothing touches gl until it returns.\n let lost = false\n let onScreen = true\n let last = 0\n // Milliseconds of motion so far; only advances while the orb is moving.\n let clock = 0\n let nextBlink = 1800 + Math.random() * 2400\n let blinkAt = -10_000\n let nextGlance = 600 + Math.random() * 900\n // When the pointer last moved, from performance.now().\n let pointerAt = -Infinity\n\n /** Returns whether the backing size changed (which clears the canvas). */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n if (width === canvas.width && height === canvas.height) return false\n canvas.width = width\n canvas.height = height\n gl.viewport(0, 0, canvas.width, canvas.height)\n return true\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n /**\n * Draws now: resizing cleared the canvas, and waiting for the next frame\n * would show it blank for one. Zero dt, so time doesn't jump; the tick\n * reschedules itself if it should keep running.\n */\n function redraw() {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2)\n const dy = (rect.top + rect.height / 2 - e.clientY) / (rect.height / 2)\n targetLook.x = Math.min(1, Math.max(-1, dx))\n targetLook.y = Math.min(1, Math.max(-1, dy))\n pointerAt = performance.now()\n // While moving the loop is already queued; a still frame follows the gaze.\n if (onScreen) wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(50, Math.max(0, now - last))\n last = now\n const live = moving()\n if (live) clock += dt\n const reduce = mqReduce.matches\n\n const resolved = resolveVariant(\n options.variant,\n options.color,\n options.eyeColor,\n options.colors,\n dark\n )\n const time = clock / 1000\n\n // The pointer steers until it rests; then the eyes look around, now\n // and then back at you. Reduced motion holds a still, centred gaze.\n const steering = options.interactive && now - pointerAt < IDLE_MS\n if (!steering && live && clock >= nextGlance) {\n const back = Math.random() < 0.3\n glance.x = back ? 0 : (Math.random() * 2 - 1) * 0.8\n glance.y = back ? 0.08 : (Math.random() * 2 - 1) * 0.5\n nextGlance = clock + 900 + Math.random() * 2100\n }\n const goal = steering ? targetLook : reduce ? REST_LOOK : glance\n // A still frame skips the easing and shows where the gaze settles.\n const ease = live ? (steering ? 0.16 : 0.1) : 1\n look.x += (goal.x - look.x) * ease\n look.y += (goal.y - look.y) * ease\n\n if (!reduce && options.blink && clock >= nextBlink) {\n blinkAt = clock\n nextBlink = clock + 2200 + Math.random() * 3800\n }\n const bt = (clock - blinkAt) / 1000\n let b = 0\n if (!reduce && options.blink) {\n if (bt < 0.055) b = bt / 0.055\n else if (bt < 0.1) b = 1\n else if (bt < 0.18) b = 1 - (bt - 0.1) / 0.08\n }\n const body = hexToRgb(resolved.body)\n const eye = hexToRgb(resolved.eye)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(\n resolved.palette[i % resolved.palette.length] ?? WEBGL_COLORS[i]!\n )\n )\n\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, reduce ? 0 : time)\n gl.uniform1f(uSpeed, reduce ? 0 : 0.55)\n gl.uniform2f(uLook, look.x, look.y)\n gl.uniform1f(uBlink, b)\n gl.uniform1f(uMode, resolved.mode)\n gl.uniform3f(uBody, body[0], body[1], body[2])\n gl.uniform3f(uEye, eye[0], eye[1], eye[2])\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n if (live && !destroyed) raf = requestAnimationFrame(tick)\n }\n\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n const syncTheme = () => {\n const next = resolveDark(options.theme)\n if (next === dark) return\n dark = next\n wake()\n }\n const unwatchTheme = watchTheme(syncTheme)\n\n // Without preventDefault the browser never hands the context back.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n // A lost canvas paints a broken-image icon over the CSS fallback.\n canvas.style.visibility = \"hidden\"\n options.onHasGl?.(false)\n }\n const onRestored = () => {\n if (destroyed) return\n // A failed rebuild leaves the orb on its CSS fallback.\n if (!initGl()) return\n lost = false\n canvas.style.visibility = \"\"\n options.onHasGl?.(true)\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n dark = resolveDark(options.theme)\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n unwatchTheme()\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n if (lost) canvas.style.visibility = \"\"\n options.onHasGl?.(false)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type LiveOrbProps = Omit & {\n className?: string\n /** Edge length in CSS pixels. Default `280`. */\n size?: number\n}\n\n/**\n * Evenly lit sphere with two capsule eyes that follow the pointer.\n * The orb stays put; only the gaze moves. Without a `variant` it is white\n * on dark pages and black on light ones.\n */\nexport function LiveOrb({\n className,\n size = 280,\n variant,\n color,\n eyeColor,\n colors,\n interactive = true,\n blink = true,\n theme = \"auto\",\n}: LiveOrbProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hasGl, setHasGl] = useState(false)\n // The CSS fallback face needs the theme too; it starts light on the\n // server and corrects itself right after mount.\n const [dark, setDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setDark(resolveDark(theme))\n sync()\n return watchTheme(sync)\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLiveOrb(canvas, {\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n theme,\n onHasGl: setHasGl,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n theme,\n onHasGl: setHasGl,\n })\n }, [variant, color, eyeColor, colors, interactive, blink, theme])\n\n const resolved = resolveVariant(variant, color, eyeColor, colors, dark)\n\n return (\n \n {!hasGl ? (\n \n \n \n \n ) : null}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/live-orb.tsx" } diff --git a/public/r/logo-burst-svelte.json b/public/r/logo-burst-svelte.json index 0cbbc4b..ebc1850 100644 --- a/public/r/logo-burst-svelte.json +++ b/public/r/logo-burst-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/logo-burst/logo-burst.svelte", - "content": "\n\n\n\n\n \n {#if children}\n \n
    \n {@render children()}\n
    \n \n {/if}\n {#if replayOnClick}\n \n {/if}\n\n", + "content": "\n\n\n\n\n
    \n {#if children}\n \n
    \n {@render children()}\n
    \n \n {/if}\n {#if replayOnClick}\n \n {/if}\n\n", "type": "registry:file", "target": "src/lib/components/ui/logo-burst.svelte" } diff --git a/public/r/logo-burst.json b/public/r/logo-burst.json index e2df4b8..a8e721b 100644 --- a/public/r/logo-burst.json +++ b/public/r/logo-burst.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/logo-burst/logo-burst.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Inner hole in CSS pixels. Lines start just outside this disc.\n * Default `0` (lines meet at the origin). Raise it when you overlay a mark.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament, reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament, reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 0\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 0, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst: filaments explode from the center, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n // Resizing clears the canvas, and a reduced-motion burst isn't looping.\n paintStill()\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n const was = reduce\n reduce = mqReduce.matches\n if (reduce) settled = true\n // Motion back on: restart the loop, which a reduced-motion paint ends.\n if (was && !reduce && running) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n paintStill()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n /** Under reduced motion there is no loop, so redraw the settled frame. */\n function paintStill() {\n if (!reduce || !running) return\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else {\n paintStill()\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Optional centered mark. Omitted by default; the burst is the hero. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click the field to replay the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Optional centered plate if you want a logo over the burst. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from the center, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || !children || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n return (\n \n \n {children ? (\n
    \n {\n coreRef.current = node\n }}\n >\n {children}\n
    \n \n ) : null}\n {replayOnClick ? (\n \n ) : null}\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Inner hole in CSS pixels. Lines start just outside this disc.\n * Default `0` (lines meet at the origin). Raise it when you overlay a mark.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament, reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament, reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 0\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 0, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst: filaments explode from the center, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n /** Returns whether the backing size changed (which clears the canvas). */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n const width = Math.floor(w * dpr)\n const height = Math.floor(h * dpr)\n const changed = width !== canvas.width || height !== canvas.height\n if (changed) {\n canvas.width = width\n canvas.height = height\n }\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return changed\n }\n\n resize()\n // Resizing cleared the canvas; paint now, or it shows blank for a frame.\n // `paint` reads the clock rather than a step, so painting early is safe,\n // and it only queues the next frame when the burst is looping.\n const ro = new ResizeObserver(() => {\n if (!resize() || !running) return\n cancelAnimationFrame(raf)\n paint(performance.now())\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n const was = reduce\n reduce = mqReduce.matches\n if (reduce) settled = true\n // Motion back on: restart the loop, which a reduced-motion paint ends.\n if (was && !reduce && running) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n paintStill()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n /** Under reduced motion there is no loop, so redraw the settled frame. */\n function paintStill() {\n if (!reduce || !running) return\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else {\n paintStill()\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Optional centered mark. Omitted by default; the burst is the hero. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click the field to replay the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Optional centered plate if you want a logo over the burst. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from the center, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || !children || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n return (\n \n \n {children ? (\n
    \n {\n coreRef.current = node\n }}\n >\n {children}\n
    \n \n ) : null}\n {replayOnClick ? (\n \n ) : null}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/logo-burst.tsx" } diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index 98cb8e0..a932207 100644 --- a/public/r/phosphor-score-svelte.json +++ b/public/r/phosphor-score-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/phosphor-score.svelte" } diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json index 11868d5..6851f0e 100644 --- a/public/r/phosphor-score.json +++ b/public/r/phosphor-score.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color, any CSS color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo and the\n * flare a note leaves as it plays.\n */\n glow?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is, `0.35`–`1.85`. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Tilt the plane about its horizontal axis, in degrees, `-70`–`70`.\n * Positive tips the incoming notes away. Default `0`.\n */\n rotateX?: number\n /**\n * Turn the plane about its vertical axis, in degrees, `-70`–`70`.\n * Positive swings the right side away. Default `0`.\n */\n rotateY?: number\n /** Spin the plane in place, in degrees. Positive is clockwise. Default `0`. */\n rotateZ?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on the page background */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\n/** Transparent in both themes; the parent's background shows through. */\nexport const DARK_BG = \"transparent\"\n/** Transparent in both themes; the parent's background shows through. */\nexport const LIGHT_BG = \"transparent\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\n/** Length of the generated score; it loops seamlessly. */\nconst LOOP_BEATS = 48\n/** Nothing is written in the last half beat, so the loop seam never collides. */\nconst LOOP_END = LOOP_BEATS - 0.5\n/** Stem length in half-spaces (3.5 staff spaces). */\nconst STEM = 7\n/** Playhead height as a share of the canvas; the rest below is afterglow. */\nconst PLAYHEAD = 0.7\n/** Share of the height faded out at the top and bottom edges. */\nconst EDGE_FADE = 0.16\n/** Seconds to crossfade when `seed` or `density` regenerates the score. */\nconst REBUILD_FADE = 0.35\n/** Seconds a flare lasts after its note plays. */\nconst FLARE_LIFE = 0.5\nconst MAX_FLARES = 48\n/** `rotateX` / `rotateY` stop short of edge-on. */\nconst MAX_TILT = 70\n/** Camera distance as a multiple of the canvas' longer side. */\nconst PERSPECTIVE = 1.2\n/** Depth scale where the plane has fully faded into the distance. */\nconst FAR_SCALE = 0.12\n/** Depth-scale range over which the plane fades in from `FAR_SCALE`. */\nconst FOG_RANGE = 0.3\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Staff = 0 | 1\ntype Rgb = [number, number, number]\n\ntype Note = {\n t: number\n /** Staff position in half-spaces; the five lines sit on 0, 2, 4, 6, 8. */\n pitch: number\n staff: Staff\n /** Half notes and longer draw hollow heads. */\n hollow: boolean\n acc: -1 | 0 | 1\n}\n\n/**\n * A stem carrier: a beamed group, a chord, or a lone note. Stems point\n * away from the middle of the system, so beams sit on the outer side and\n * dynamics have the space between the staves to themselves.\n */\ntype Stem = {\n staff: Staff\n notes: Note[]\n t0: number\n t1: number\n /** Where the stems end (and the beam runs), in half-spaces. */\n tip: number\n /** 0 for chords and lone notes, 1 for eighths, 2 for sixteenths. */\n beams: 0 | 1 | 2\n chord: boolean\n}\n\ntype Mark = { t: number; text: string }\ntype Hairpin = { t: number; dur: number; opening: boolean }\ntype Slur = { staff: Staff; first: Note; last: Note }\n\ntype Score = {\n notes: Note[]\n stems: Stem[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n}\n\ntype Dust = { x: number; y: number; z: number; s: number; a: number }\ntype Flare = { staff: Staff; pitch: number; age: number }\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\n/** Signed beats from `now` to `t`, taking the shorter way round the loop. */\nfunction wrapDelta(t: number, now: number) {\n const d = t - now\n return (\n ((((d + LOOP_BEATS / 2) % LOOP_BEATS) + LOOP_BEATS) % LOOP_BEATS) -\n LOOP_BEATS / 2\n )\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\nconst glowOf = (value?: number) => numberOr(value, DEFAULT_GLOW, 0, 100)\nconst speedOf = (value?: number) => numberOr(value, DEFAULT_SPEED, 0.15, 4)\nconst densityOf = (value?: number) =>\n numberOr(value, DEFAULT_DENSITY, 0.35, 1.85)\nconst seedOf = (value?: number) =>\n Math.round(numberOr(value, DEFAULT_SEED, 0, 2 ** 32 - 1)) >>> 0\n\nconst tiltOf = (value?: number) => numberOr(value, 0, -MAX_TILT, MAX_TILT)\nconst spinOf = (value?: number) => {\n const deg = numberOr(value, 0, -Infinity, Infinity)\n return ((((deg + 180) % 360) + 360) % 360) - 180\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\n/** Any CSS color → rgb, using the canvas' own parser. */\nfunction parseColor(\n ctx: CanvasRenderingContext2D,\n input: string,\n fallback: string\n): Rgb {\n ctx.fillStyle = fallback\n ctx.fillStyle = input\n const value = String(ctx.fillStyle)\n if (value.startsWith(\"#\") && value.length === 7) {\n return [\n parseInt(value.slice(1, 3), 16),\n parseInt(value.slice(3, 5), 16),\n parseInt(value.slice(5, 7), 16),\n ]\n }\n const parts = value.match(/[\\d.]+/g)?.map(Number) ?? []\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${clamp(a, 0, 1)})`\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const stems: Stem[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n\n const note = (t: number, pitch: number, staff: Staff, hollow = false) => {\n const n: Note = {\n t,\n pitch: clamp(Math.round(pitch), -3, 11),\n staff,\n hollow,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n }\n notes.push(n)\n return n\n }\n\n const addStem = (\n staff: Staff,\n list: Note[],\n beams: Stem[\"beams\"],\n chord = false\n ) => {\n const pitches = list.map((n) => n.pitch)\n const tip =\n staff === 0 ? Math.min(...pitches) - STEM : Math.max(...pitches) + STEM\n stems.push({\n staff,\n notes: list,\n t0: list[0]!.t,\n t1: list[list.length - 1]!.t,\n tip,\n beams: list.length > 1 && !chord ? beams : 0,\n chord,\n })\n }\n\n const run = (\n staff: Staff,\n start: number,\n pitch: number,\n count: number,\n step: number\n ) => {\n let dir = rng() < 0.5 ? 1 : -1\n let p = pitch\n const list: Note[] = []\n for (let i = 0; i < count; i++) {\n if (i === 0 || rng() > 0.12 / density)\n list.push(note(start + i * step, p, staff))\n p += dir * (rng() < 0.18 ? 2 : 1)\n if (p >= 10 || p <= -2) {\n dir = -dir\n p = clamp(p, -2, 10)\n }\n }\n // A run thinned down to one note is drawn as a lone quarter.\n addStem(staff, list, step <= 0.25 ? 2 : 1)\n if (list.length >= 4 && rng() < 0.45) {\n slurs.push({ staff, first: list[0]!, last: list[list.length - 1]! })\n }\n }\n\n const arpeggio = (\n staff: Staff,\n start: number,\n root: number,\n step: number\n ) => {\n const shape = [0, 2, 4, 7, 4, 2, 0, -1]\n const list = shape.map((offset, i) =>\n note(start + i * step, root + offset, staff)\n )\n addStem(staff, list, step <= 0.25 ? 2 : 1)\n }\n\n const chord = (staff: Staff, t: number, root: number, hollow: boolean) => {\n const list = [\n note(t, root, staff, hollow),\n note(t, root + 2, staff, hollow),\n ]\n if (rng() < 0.7) list.push(note(t, root + 4, staff, hollow))\n addStem(staff, list, 0, true)\n }\n\n let t = 0\n while (t < LOOP_END) {\n const room = LOOP_END - t\n const kind = rng()\n const lead: Staff = rng() < 0.55 ? 0 : 1\n const other: Staff = lead === 0 ? 1 : 0\n const phraseStart = t\n\n if (kind < 0.5) {\n const count = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.35 * density ? 0.25 : 0.5\n const length = count * step\n if (length > room) break\n run(lead, t, Math.floor(rng() * 7) + 1, count, step)\n if (rng() < 0.55 * density) {\n chord(other, t, 1 + Math.floor(rng() * 4), length >= 2 && rng() < 0.5)\n }\n t += length\n } else if (kind < 0.72) {\n if (room < 2) break\n arpeggio(lead, t, 1 + Math.floor(rng() * 4), 0.25)\n if (rng() < 0.7 * density)\n run(other, t, Math.floor(rng() * 4) + 1, 4, 0.5)\n t += 2\n } else if (kind < 0.88) {\n const hollow = rng() < 0.45\n const length = hollow ? 2 : 1\n if (length > room) break\n chord(0, t, 2 + Math.floor(rng() * 4), hollow)\n chord(1, t, Math.floor(rng() * 4), hollow)\n t += length\n } else {\n // A rest. Sparser scores rest longer.\n t += density < 0.8 ? 1 : 0.5\n continue\n }\n\n if (rng() < 0.2)\n marks.push({\n t: phraseStart,\n text: DYNAMICS[Math.floor(rng() * DYNAMICS.length)]!,\n })\n if (rng() < 0.14) {\n const dur = Math.min(2 + Math.floor(rng() * 3), LOOP_END - phraseStart)\n if (dur >= 1) hairpins.push({ t: phraseStart, dur, opening: rng() < 0.5 })\n }\n }\n\n return { notes, stems, marks, hairpins, slurs }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n return Array.from({ length: 90 }, () => ({\n x: rng(),\n y: rng(),\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n }))\n}\n\n/** A soft round glow in `rgb`, drawn once and stamped with `drawImage`. */\nfunction makeGlowSprite(rgb: Rgb) {\n const size = 64\n const sprite = document.createElement(\"canvas\")\n sprite.width = size\n sprite.height = size\n const g = sprite.getContext(\"2d\")\n if (!g) return sprite\n const grad = g.createRadialGradient(\n size / 2,\n size / 2,\n 0,\n size / 2,\n size / 2,\n size / 2\n )\n grad.addColorStop(0, rgba(rgb, 1))\n grad.addColorStop(0.25, rgba(rgb, 0.45))\n grad.addColorStop(0.6, rgba(rgb, 0.1))\n grad.addColorStop(1, rgba(rgb, 0))\n g.fillStyle = grad\n g.fillRect(0, 0, size, size)\n return sprite\n}\n\n/** Brightness at the playhead: ramps in just before, decays after (phosphor persistence). */\nfunction litAt(d: number) {\n if (d > 0.3) return 0\n if (d >= 0) {\n const k = 1 - d / 0.3\n return k * k\n }\n return Math.exp(d * 2.4)\n}\n\n/**\n * How the score plane sits in front of the camera. Plane coordinates are\n * the unrotated canvas pixels; it turns about the playhead, which is also\n * where perspective converges.\n */\ntype View = {\n /** `rotateX · rotateY · rotateZ`, row-major, CSS axes (y down, z toward you). */\n m: number[]\n ox: number\n oy: number\n /** Camera distance. */\n cam: number\n /** No depth variation across the plane, so nothing foreshortens or fogs. */\n depthless: boolean\n /** Bounds of the plane that are on screen and not lost in the distance. */\n x0: number\n x1: number\n y0: number\n y1: number\n}\n\nfunction makeView(\n w: number,\n h: number,\n rotateX: number,\n rotateY: number,\n rotateZ: number\n): View {\n const rad = Math.PI / 180\n const [cx, sx] = [Math.cos(rotateX * rad), Math.sin(rotateX * rad)]\n const [cy, sy] = [Math.cos(rotateY * rad), Math.sin(rotateY * rad)]\n const [cz, sz] = [Math.cos(rotateZ * rad), Math.sin(rotateZ * rad)]\n // Rx · Ry · Rz, the order CSS applies `rotateX() rotateY() rotateZ()`.\n const m = [\n cy * cz,\n -cy * sz,\n sy,\n sx * sy * cz + cx * sz,\n -sx * sy * sz + cx * cz,\n -sx * cy,\n -cx * sy * cz + sx * sz,\n cx * sy * sz + sx * cz,\n cx * cy,\n ]\n const ox = w / 2\n const oy = h * PLAYHEAD\n const cam = Math.max(w, h) * PERSPECTIVE\n const view: View = {\n m,\n ox,\n oy,\n cam,\n depthless: Math.abs(m[6]!) + Math.abs(m[7]!) < 1e-6,\n x0: 0,\n x1: w,\n y0: 0,\n y1: h,\n }\n if (rotateX === 0 && rotateY === 0 && rotateZ === 0) return view\n\n // The screen, less whatever lies beyond FAR_SCALE, traced back onto the\n // plane. Along a ray through screen point (u, v) the plane is hit at\n // t = m22·cam / (m22·cam − m02·u − m12·v), with depth scale 1 / t, so\n // \"not too far\" is a straight line on screen and clips the rectangle.\n const [m02, m12, m22] = [m[2]!, m[5]!, m[8]!]\n const far = (u: number, v: number) =>\n m22 * cam * (1 - FAR_SCALE) - m02 * u - m12 * v\n const corners: [number, number][] = [\n [-ox, -oy],\n [w - ox, -oy],\n [w - ox, h - oy],\n [-ox, h - oy],\n ]\n const kept: [number, number][] = []\n corners.forEach((a, i) => {\n const b = corners[(i + 1) % corners.length]!\n const fa = far(a[0], a[1])\n const fb = far(b[0], b[1])\n if (fa >= 0) kept.push(a)\n if (fa >= 0 !== fb >= 0) {\n const k = fa / (fa - fb)\n kept.push([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k])\n }\n })\n\n view.x0 = view.y0 = Infinity\n view.x1 = view.y1 = -Infinity\n for (const [u, v] of kept) {\n const t = (m22 * cam) / (m22 * cam - m02 * u - m12 * v)\n const [wx, wy, wz] = [t * u, t * v, cam - t * cam]\n const px = m[0]! * wx + m[3]! * wy + m[6]! * wz + ox\n const py = m[1]! * wx + m[4]! * wy + m[7]! * wz + oy\n view.x0 = Math.min(view.x0, px)\n view.x1 = Math.max(view.x1, px)\n view.y0 = Math.min(view.y0, py)\n view.y1 = Math.max(view.y1, py)\n }\n return view\n}\n\n/** Fades the plane out as it recedes toward `FAR_SCALE`. */\nfunction fogOf(scale: number) {\n const k = clamp((scale - FAR_SCALE) / FOG_RANGE, 0, 1)\n return k * k * (3 - 2 * k)\n}\n\n/**\n * Vertical phosphor sheet music: a grand staff scrolls down toward a\n * playhead, each note blooms and flares as it plays, then fades with a\n * phosphor afterglow. Canvas 2D; pauses off-screen and in hidden tabs, and\n * holds a still frame under `prefers-reduced-motion`.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let previous: Score | null = null\n let rebuildAge = REBUILD_FADE\n let dust = makeDust(seedOf(options.seed))\n\n let dark = resolveDark(themeOf(options.theme))\n let rgb: Rgb = [0, 0, 0]\n let glowSprite = document.createElement(\"canvas\")\n const recolor = () => {\n rgb = parseColor(\n ctx,\n resolveColor(options.color, dark),\n dark ? DARK_COLOR : LIGHT_COLOR\n )\n glowSprite = makeGlowSprite(rgb)\n }\n recolor()\n options.onThemeChange?.(dark)\n\n const flares: Flare[] = []\n const size = { w: 0, h: 0, dpr: 1 }\n let reduce = false\n let onScreen = true\n let running = true\n let raf = 0\n let lastFrame = 0\n // Start a few bars in, so the first frame already has notes at the playhead.\n let beat = 8\n let clock = 0\n let view = makeView(1, 1, 0, 0, 0)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w === size.w && h === size.h && dpr === size.dpr) return\n size.w = w\n size.h = h\n size.dpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n wake()\n }\n\n const shouldAnimate = () =>\n running && onScreen && !reduce && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (!running || raf) return\n lastFrame = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n recolor()\n options.onThemeChange?.(dark)\n wake()\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n wake()\n }\n reduce = mqReduce.matches\n mqReduce.addEventListener(\"change\", onReduce)\n\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n function tick(now: number) {\n raf = 0\n if (!running) return\n if (Math.min(window.devicePixelRatio || 1, 2) !== size.dpr) resize()\n\n const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000))\n lastFrame = now\n const animate = shouldAnimate()\n const before = beat\n if (animate) {\n clock += dt\n beat = (beat + dt * speedOf(options.speed)) % LOOP_BEATS\n rebuildAge += dt\n spawnFlares(before, beat)\n } else {\n rebuildAge = REBUILD_FADE\n flares.length = 0\n }\n for (let i = flares.length - 1; i >= 0; i--) {\n flares[i]!.age += dt\n if (flares[i]!.age >= FLARE_LIFE) flares.splice(i, 1)\n }\n\n draw()\n if (animate) raf = requestAnimationFrame(tick)\n }\n\n /** Every note whose time was crossed since the last frame flares once; no frame can skip one. */\n function spawnFlares(from: number, to: number) {\n const span = wrapDelta(to, from)\n if (span <= 0) return\n for (const n of score.notes) {\n const d = wrapDelta(n.t, from)\n if (d > 0 && d <= span) {\n if (flares.length >= MAX_FLARES) flares.shift()\n flares.push({ staff: n.staff, pitch: n.pitch, age: 0 })\n }\n }\n }\n\n function draw() {\n const { w, h, dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.globalCompositeOperation = \"source-over\"\n ctx!.globalAlpha = 1\n ctx!.clearRect(0, 0, w, h)\n if (w < 2 || h < 2) return\n\n view = makeView(\n w,\n h,\n tiltOf(options.rotateX),\n tiltOf(options.rotateY),\n spinOf(options.rotateZ)\n )\n const swayOn = options.sway !== false && !reduce\n const cam = {\n x: swayOn ? Math.cos(clock * 0.23) * 10 : 0,\n y: swayOn ? Math.sin(clock * 0.31) * 8 : 0,\n }\n const sp = clamp(Math.min(w, h) * 0.021, 5, 11)\n const layout = {\n sp,\n gap: clamp(w * 0.16, sp * 6.5, sp * 12),\n ppb: clamp(h * 0.15, sp * 5.2, sp * 11),\n cx: w / 2 + cam.x,\n playY: h * PLAYHEAD + cam.y,\n }\n const bloom = glowOf(options.glow) / 50\n\n // Dust drifts in screen space, beneath everything.\n for (const d of dust) {\n const px = (d.x + clock * 0.01 * d.z) % 1\n const py = (d.y + beat * 0.004 * (0.3 + d.z)) % 1\n ctx!.fillStyle = rgba(rgb, dark ? d.a : d.a * 0.35)\n ctx!.fillRect(px * w, py * h, d.s, d.s)\n }\n\n drawStaves(layout)\n\n const fade = previous ? clamp(rebuildAge / REBUILD_FADE, 0, 1) : 1\n if (previous && fade >= 1) previous = null\n if (previous) drawScore(previous, layout, 1 - fade, 0)\n drawScore(score, layout, fade, bloom)\n\n drawPlayhead(layout, bloom)\n drawFlares(layout, bloom)\n drawEdgeFade(w, h)\n }\n\n type Layout = {\n sp: number\n gap: number\n ppb: number\n cx: number\n playY: number\n }\n\n const xOf = (l: Layout, staff: Staff, pitch: number) =>\n l.cx + (staff === 0 ? -l.gap : l.gap) + (pitch - 4) * (l.sp / 2)\n const yOf = (l: Layout, d: number) => l.playY - d * l.ppb\n\n /**\n * Every beat offset at which something at time `t`, lasting `len` beats,\n * is in view. Usually one; a steep tilt can see several loops ahead.\n */\n function spots(l: Layout, t: number, len = 0) {\n const minD = (l.playY - view.y1 - l.ppb) / l.ppb\n const maxD = (l.playY - view.y0 + l.ppb) / l.ppb\n const first = wrapDelta(t, beat)\n const out: number[] = []\n const from = Math.ceil((minD - len - first) / LOOP_BEATS)\n const to = Math.floor((maxD - first) / LOOP_BEATS)\n for (let k = from; k <= to; k++) out.push(first + k * LOOP_BEATS)\n return out\n }\n\n /**\n * Plane point → screen `[x, y, scale]`, where scale is how much the\n * perspective enlarges it there. Scale is `0` behind the camera.\n */\n function project(x: number, y: number): [number, number, number] {\n const { m, ox, oy, cam } = view\n const px = x - ox\n const py = y - oy\n const z = m[6]! * px + m[7]! * py\n if (cam - z < cam * 0.02) return [0, 0, 0]\n const s = cam / (cam - z)\n return [\n ox + (m[0]! * px + m[1]! * py) * s,\n oy + (m[3]! * px + m[4]! * py) * s,\n s,\n ]\n }\n\n /**\n * Moves the canvas into the plane at `(x, y)`, so a notehead, glyph or\n * glow drawn around the origin tilts and foreshortens with the score.\n * Returns the distance fog there; `leave()` restores screen space.\n */\n function enter(x: number, y: number) {\n const { m, ox, oy, cam } = view\n const { dpr } = size\n const px = x - ox\n const py = y - oy\n const qx = m[0]! * px + m[1]! * py\n const qy = m[3]! * px + m[4]! * py\n const z = m[6]! * px + m[7]! * py\n if (cam - z < cam * 0.02) return 0\n const s = cam / (cam - z)\n // Jacobian of the projection: how a unit step along the plane lands on screen.\n const k = (s * s) / cam\n ctx!.setTransform(\n dpr * (s * m[0]! + qx * k * m[6]!),\n dpr * (s * m[3]! + qy * k * m[6]!),\n dpr * (s * m[1]! + qx * k * m[7]!),\n dpr * (s * m[4]! + qy * k * m[7]!),\n dpr * (ox + qx * s),\n dpr * (oy + qy * s)\n )\n return fogOf(s)\n }\n\n function leave() {\n const { dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.globalAlpha = 1\n }\n\n /** Plane stroke width; `line` and `polyline` scale it with depth. */\n let lw = 1\n\n /**\n * A straight stroke on the plane. Long ones pass `segments` so the fog\n * can thin them as they recede.\n */\n function line(x0: number, y0: number, x1: number, y1: number, segments = 1) {\n const n = view.depthless ? 1 : segments\n for (let i = 0; i < n; i++) {\n const a = i / n\n const b = (i + 1) / n\n polyline([\n [x0 + (x1 - x0) * a, y0 + (y1 - y0) * a],\n [x0 + (x1 - x0) * b, y0 + (y1 - y0) * b],\n ])\n }\n }\n\n function polyline(points: [number, number][]) {\n let scale = 0\n ctx!.beginPath()\n for (const [i, [x, y]] of points.entries()) {\n const p = project(x, y)\n if (p[2] <= 0) return\n scale += p[2]\n if (i === 0) ctx!.moveTo(p[0], p[1])\n else ctx!.lineTo(p[0], p[1])\n }\n scale /= points.length\n const fog = fogOf(scale)\n if (fog <= 0) return\n ctx!.globalAlpha = fog\n ctx!.lineWidth = lw * scale\n ctx!.stroke()\n ctx!.globalAlpha = 1\n }\n\n /** A quadratic curve on the plane; its control point is projected too. */\n function curve(\n x0: number,\n y0: number,\n cx: number,\n cy: number,\n x1: number,\n y1: number\n ) {\n const a = project(x0, y0)\n const c = project(cx, cy)\n const b = project(x1, y1)\n if (a[2] <= 0 || b[2] <= 0 || c[2] <= 0) return\n const scale = (a[2] + b[2]) / 2\n const fog = fogOf(scale)\n if (fog <= 0) return\n ctx!.globalAlpha = fog\n ctx!.lineWidth = lw * scale\n ctx!.beginPath()\n ctx!.moveTo(a[0], a[1])\n ctx!.quadraticCurveTo(c[0], c[1], b[0], b[1])\n ctx!.stroke()\n ctx!.globalAlpha = 1\n }\n\n /** The glow sprite, stretched to `rx` × `ry` around `(x, y)` on the plane. */\n function glow(x: number, y: number, rx: number, ry: number, alpha: number) {\n const fog = enter(x, y)\n if (fog > 0) {\n ctx!.globalAlpha = alpha * fog\n ctx!.drawImage(glowSprite, -rx, -ry, rx * 2, ry * 2)\n }\n leave()\n }\n\n function drawStaves(l: Layout) {\n ctx!.lineCap = \"butt\"\n lw = 1\n ctx!.strokeStyle = rgba(rgb, dark ? 0.2 : 0.24)\n for (const staff of [0, 1] as const) {\n for (let p = 0; p <= 8; p += 2) {\n const x = Math.round(xOf(l, staff, p)) + 0.5\n line(x, view.y0, x, view.y1, 16)\n }\n }\n // Bar lines every four beats, through both staves like a grand staff.\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n for (const d of spots(l, bar)) {\n const y = yOf(l, d)\n ctx!.strokeStyle = rgba(rgb, (dark ? 0.16 : 0.2) + litAt(d) * 0.3)\n line(xOf(l, 0, 0), y, xOf(l, 1, 8), y)\n }\n }\n }\n\n function drawScore(s: Score, l: Layout, opacity: number, bloom: number) {\n if (opacity <= 0) return\n const base = (d: number) =>\n d >= 0 ? (dark ? 0.46 : 0.58) : dark ? 0.22 : 0.3\n const alphaAt = (d: number) => {\n const b = base(d)\n return (b + (1 - b) * litAt(d)) * opacity\n }\n const sp = l.sp\n const rx = sp * 0.5\n const ry = sp * 0.64\n\n ctx!.lineCap = \"butt\"\n\n // Dynamics and hairpins share the space between the staves.\n ctx!.font = `italic ${Math.round(clamp(sp * 1.3, 10, 17))}px Georgia, \"Times New Roman\", serif`\n ctx!.textAlign = \"center\"\n ctx!.textBaseline = \"middle\"\n for (const mark of s.marks) {\n for (const d of spots(l, mark.t)) {\n const fog = enter(l.cx, yOf(l, d) + sp * 0.9)\n if (fog > 0) {\n ctx!.globalAlpha = fog\n ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85)\n ctx!.fillText(mark.text, 0, 0)\n }\n leave()\n }\n }\n lw = 1\n for (const pin of s.hairpins) {\n for (const d0 of spots(l, pin.t, pin.dur)) {\n const d1 = d0 + pin.dur\n const narrow = pin.opening ? d0 : d1\n const wide = pin.opening ? d1 : d0\n const half = sp * 0.9\n ctx!.strokeStyle = rgba(\n rgb,\n alphaAt(Math.min(Math.abs(d0), Math.abs(d1))) * 0.6\n )\n polyline([\n [l.cx - half, yOf(l, wide)],\n [l.cx, yOf(l, narrow)],\n [l.cx + half, yOf(l, wide)],\n ])\n }\n }\n\n // Slurs curve on the inner side, opposite the stems.\n lw = Math.max(1, sp * 0.1)\n for (const slur of s.slurs) {\n const len = slur.last.t - slur.first.t\n for (const d0 of spots(l, slur.first.t, len)) {\n const d1 = d0 + len\n const inward = slur.staff === 0 ? 1 : -1\n const y0 = yOf(l, d0)\n const y1 = yOf(l, d1)\n const x0 = xOf(l, slur.staff, slur.first.pitch + inward * 2)\n const x1 = xOf(l, slur.staff, slur.last.pitch + inward * 2)\n const edge =\n inward > 0\n ? Math.max(slur.first.pitch, slur.last.pitch) + 3.5\n : Math.min(slur.first.pitch, slur.last.pitch) - 3.5\n ctx!.strokeStyle = rgba(\n rgb,\n alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5\n )\n curve(x0, y0, xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1)\n }\n }\n\n // Stems and beams. A stem runs along the pitch axis from the side of its\n // head (the time axis is \"across\" here), out to the beam.\n const stemW = Math.max(1, sp * 0.11)\n const side = -ry * 0.8\n for (const stem of s.stems) {\n const len = stem.t1 - stem.t0\n for (const d0 of spots(l, stem.t0, len)) {\n const d1 = d0 + len\n const out = stem.staff === 0 ? -1 : 1\n const tipX = xOf(l, stem.staff, stem.tip)\n lw = stemW\n if (stem.chord) {\n // One stem through the chord, from the innermost head out to the tip.\n const inner =\n out < 0\n ? Math.max(...stem.notes.map((n) => n.pitch))\n : Math.min(...stem.notes.map((n) => n.pitch))\n const y = yOf(l, d0) + side\n ctx!.strokeStyle = rgba(rgb, alphaAt(d0))\n line(xOf(l, stem.staff, inner), y, tipX, y)\n continue\n }\n for (const n of stem.notes) {\n const d = d0 + (n.t - stem.t0)\n const y = yOf(l, d) + side\n ctx!.strokeStyle = rgba(rgb, alphaAt(d))\n line(xOf(l, stem.staff, n.pitch), y, tipX, y)\n }\n if (stem.beams === 0) continue\n const nearest = d1 < 0 ? d1 : d0 > 0 ? d0 : 0\n ctx!.strokeStyle = rgba(rgb, alphaAt(nearest))\n lw = sp * 0.36\n const yA = yOf(l, d0) + side + stemW / 2\n const yB = yOf(l, d1) + side - stemW / 2\n const offset = sp * 0.18 * out\n line(tipX - offset, yA, tipX - offset, yB)\n if (stem.beams === 2) {\n const x2 = tipX - offset - out * sp * 0.75\n line(x2, yA, x2, yB)\n }\n }\n }\n\n // Ledger lines, parallel to the staff lines.\n lw = 1\n for (const n of s.notes) {\n if (n.pitch > -2 && n.pitch < 10) continue\n for (const d of spots(l, n.t)) {\n const y = yOf(l, d)\n ctx!.strokeStyle = rgba(rgb, alphaAt(d) * 0.7)\n const from = n.pitch < 0 ? -2 : 10\n const step = n.pitch < 0 ? -2 : 2\n for (let p = from; step < 0 ? p >= n.pitch : p <= n.pitch; p += step) {\n const x = xOf(l, n.staff, p)\n line(x, y - ry * 1.7, x, y + ry * 1.7)\n }\n }\n }\n\n // Glow under the heads that are playing.\n if (bloom > 0) {\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n for (const n of s.notes) {\n for (const d of spots(l, n.t)) {\n const lit = litAt(d)\n if (lit < 0.03) continue\n const r = sp * (1.1 + lit * 1.5) * bloom\n glow(\n xOf(l, n.staff, n.pitch),\n yOf(l, d),\n r,\n r,\n lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity\n )\n }\n }\n ctx!.globalCompositeOperation = \"source-over\"\n }\n\n // Noteheads, with accidentals just before them in reading order (below).\n ctx!.font = `${Math.round(sp * 1.5)}px Georgia, \"Times New Roman\", serif`\n for (const n of s.notes) {\n for (const d of spots(l, n.t)) {\n const fog = enter(xOf(l, n.staff, n.pitch), yOf(l, d))\n if (fog <= 0) {\n leave()\n continue\n }\n ctx!.globalAlpha = fog\n const color = rgba(rgb, alphaAt(d))\n ctx!.beginPath()\n ctx!.ellipse(0, 0, rx, ry, 0.35, 0, Math.PI * 2)\n if (n.hollow) {\n ctx!.strokeStyle = color\n ctx!.lineWidth = Math.max(1.2, sp * 0.16)\n ctx!.stroke()\n } else {\n ctx!.fillStyle = color\n ctx!.fill()\n }\n if (n.acc !== 0) {\n ctx!.fillStyle = color\n ctx!.fillText(n.acc > 0 ? \"♯\" : \"♭\", 0, ry + sp * 0.95)\n }\n leave()\n }\n }\n }\n\n function drawPlayhead(l: Layout, bloom: number) {\n const y = Math.round(l.playY) + 0.5\n if (bloom > 0) {\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n const alpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom)\n const band = l.sp * 1.4 * Math.max(0.6, bloom)\n // In slices, so the band follows the perspective across the plane.\n const slices = view.depthless ? 1 : 24\n const span = (view.x1 - view.x0) / slices\n const src = glowSprite.width / slices\n for (let i = 0; i < slices; i++) {\n const fog = enter(view.x0 + (i + 0.5) * span, y)\n if (fog > 0) {\n ctx!.globalAlpha = alpha * fog\n ctx!.drawImage(\n glowSprite,\n i * src,\n 0,\n src,\n glowSprite.height,\n -span / 2,\n -band,\n span,\n band * 2\n )\n }\n leave()\n }\n ctx!.globalCompositeOperation = \"source-over\"\n }\n ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55)\n lw = 1\n line(view.x0, y, view.x1, y, 12)\n }\n\n function drawFlares(l: Layout, bloom: number) {\n if (flares.length === 0 || bloom <= 0) return\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n for (const f of flares) {\n const k = f.age / FLARE_LIFE\n const fade = (1 - k) * (1 - k)\n const x = xOf(l, f.staff, f.pitch)\n const r = l.sp * (1.6 + k * 1.4) * bloom\n glow(x, l.playY, r, r, fade * (dark ? 0.8 : 0.4))\n // A streak along the playhead.\n const streak = l.sp * (2.5 + k * 3) * bloom\n glow(x, l.playY, streak, 1.5, fade * (dark ? 0.9 : 0.5))\n }\n ctx!.globalCompositeOperation = \"source-over\"\n }\n\n /** Top and bottom fade into the background, in the canvas, so it costs nothing and blurs nothing. */\n function drawEdgeFade(w: number, h: number) {\n const f = h * EDGE_FADE\n // Erase toward the edges rather than paint over them, so the fade\n // lands on whatever background the parent has.\n const solid = \"rgba(0,0,0,1)\"\n const clear = \"rgba(0,0,0,0)\"\n ctx!.globalCompositeOperation = \"destination-out\"\n const top = ctx!.createLinearGradient(0, 0, 0, f)\n top.addColorStop(0, solid)\n top.addColorStop(1, clear)\n ctx!.fillStyle = top\n ctx!.fillRect(0, 0, w, f)\n const bottom = ctx!.createLinearGradient(0, h - f, 0, h)\n bottom.addColorStop(0, clear)\n bottom.addColorStop(1, solid)\n ctx!.fillStyle = bottom\n ctx!.fillRect(0, h - f, w, f)\n ctx!.globalCompositeOperation = \"source-over\"\n }\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDensity = densityOf(options.density)\n const prevColor = options.color\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n if (options.color !== prevColor) recolor()\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDensity\n ) {\n // Crossfade from the old score instead of cutting.\n previous = reduce ? null : score\n rebuildAge = reduce ? REBUILD_FADE : 0\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n flares.length = 0\n }\n wake()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music: a grand staff scrolls down to a playhead,\n * each note blooms and flares as it plays, then fades with an afterglow.\n * Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n rotateX = 0,\n rotateY = 0,\n rotateZ = 0,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n rotateX,\n rotateY,\n rotateZ,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n rotateX,\n rotateY,\n rotateZ,\n theme,\n })\n }, [\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n rotateX,\n rotateY,\n rotateZ,\n theme,\n ])\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color, any CSS color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo and the\n * flare a note leaves as it plays.\n */\n glow?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is, `0.35`–`1.85`. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Tilt the plane about its horizontal axis, in degrees, `-70`–`70`.\n * Positive tips the incoming notes away. Default `0`.\n */\n rotateX?: number\n /**\n * Turn the plane about its vertical axis, in degrees, `-70`–`70`.\n * Positive swings the right side away. Default `0`.\n */\n rotateY?: number\n /** Spin the plane in place, in degrees. Positive is clockwise. Default `0`. */\n rotateZ?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on the page background */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\n/** Transparent in both themes; the parent's background shows through. */\nexport const DARK_BG = \"transparent\"\n/** Transparent in both themes; the parent's background shows through. */\nexport const LIGHT_BG = \"transparent\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\n/** Length of the generated score; it loops seamlessly. */\nconst LOOP_BEATS = 48\n/** Nothing is written in the last half beat, so the loop seam never collides. */\nconst LOOP_END = LOOP_BEATS - 0.5\n/** Stem length in half-spaces (3.5 staff spaces). */\nconst STEM = 7\n/** Playhead height as a share of the canvas; the rest below is afterglow. */\nconst PLAYHEAD = 0.7\n/** Share of the height faded out at the top and bottom edges. */\nconst EDGE_FADE = 0.16\n/** Seconds to crossfade when `seed` or `density` regenerates the score. */\nconst REBUILD_FADE = 0.35\n/** Seconds a flare lasts after its note plays. */\nconst FLARE_LIFE = 0.5\nconst MAX_FLARES = 48\n/** `rotateX` / `rotateY` stop short of edge-on. */\nconst MAX_TILT = 70\n/** Camera distance as a multiple of the canvas' longer side. */\nconst PERSPECTIVE = 1.2\n/** Depth scale where the plane has fully faded into the distance. */\nconst FAR_SCALE = 0.12\n/** Depth-scale range over which the plane fades in from `FAR_SCALE`. */\nconst FOG_RANGE = 0.3\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Staff = 0 | 1\ntype Rgb = [number, number, number]\n\ntype Note = {\n t: number\n /** Staff position in half-spaces; the five lines sit on 0, 2, 4, 6, 8. */\n pitch: number\n staff: Staff\n /** Half notes and longer draw hollow heads. */\n hollow: boolean\n acc: -1 | 0 | 1\n}\n\n/**\n * A stem carrier: a beamed group, a chord, or a lone note. Stems point\n * away from the middle of the system, so beams sit on the outer side and\n * dynamics have the space between the staves to themselves.\n */\ntype Stem = {\n staff: Staff\n notes: Note[]\n t0: number\n t1: number\n /** Where the stems end (and the beam runs), in half-spaces. */\n tip: number\n /** 0 for chords and lone notes, 1 for eighths, 2 for sixteenths. */\n beams: 0 | 1 | 2\n chord: boolean\n}\n\ntype Mark = { t: number; text: string }\ntype Hairpin = { t: number; dur: number; opening: boolean }\ntype Slur = { staff: Staff; first: Note; last: Note }\n\ntype Score = {\n notes: Note[]\n stems: Stem[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n}\n\ntype Dust = { x: number; y: number; z: number; s: number; a: number }\ntype Flare = { staff: Staff; pitch: number; age: number }\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\n/** Signed beats from `now` to `t`, taking the shorter way round the loop. */\nfunction wrapDelta(t: number, now: number) {\n const d = t - now\n return (\n ((((d + LOOP_BEATS / 2) % LOOP_BEATS) + LOOP_BEATS) % LOOP_BEATS) -\n LOOP_BEATS / 2\n )\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\nconst glowOf = (value?: number) => numberOr(value, DEFAULT_GLOW, 0, 100)\nconst speedOf = (value?: number) => numberOr(value, DEFAULT_SPEED, 0.15, 4)\nconst densityOf = (value?: number) =>\n numberOr(value, DEFAULT_DENSITY, 0.35, 1.85)\nconst seedOf = (value?: number) =>\n Math.round(numberOr(value, DEFAULT_SEED, 0, 2 ** 32 - 1)) >>> 0\n\nconst tiltOf = (value?: number) => numberOr(value, 0, -MAX_TILT, MAX_TILT)\nconst spinOf = (value?: number) => {\n const deg = numberOr(value, 0, -Infinity, Infinity)\n return ((((deg + 180) % 360) + 360) % 360) - 180\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\n/** Any CSS color → rgb, using the canvas' own parser. */\nfunction parseColor(\n ctx: CanvasRenderingContext2D,\n input: string,\n fallback: string\n): Rgb {\n ctx.fillStyle = fallback\n ctx.fillStyle = input\n const value = String(ctx.fillStyle)\n if (value.startsWith(\"#\") && value.length === 7) {\n return [\n parseInt(value.slice(1, 3), 16),\n parseInt(value.slice(3, 5), 16),\n parseInt(value.slice(5, 7), 16),\n ]\n }\n const parts = value.match(/[\\d.]+/g)?.map(Number) ?? []\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${clamp(a, 0, 1)})`\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const stems: Stem[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n\n const note = (t: number, pitch: number, staff: Staff, hollow = false) => {\n const n: Note = {\n t,\n pitch: clamp(Math.round(pitch), -3, 11),\n staff,\n hollow,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n }\n notes.push(n)\n return n\n }\n\n const addStem = (\n staff: Staff,\n list: Note[],\n beams: Stem[\"beams\"],\n chord = false\n ) => {\n const pitches = list.map((n) => n.pitch)\n const tip =\n staff === 0 ? Math.min(...pitches) - STEM : Math.max(...pitches) + STEM\n stems.push({\n staff,\n notes: list,\n t0: list[0]!.t,\n t1: list[list.length - 1]!.t,\n tip,\n beams: list.length > 1 && !chord ? beams : 0,\n chord,\n })\n }\n\n const run = (\n staff: Staff,\n start: number,\n pitch: number,\n count: number,\n step: number\n ) => {\n let dir = rng() < 0.5 ? 1 : -1\n let p = pitch\n const list: Note[] = []\n for (let i = 0; i < count; i++) {\n if (i === 0 || rng() > 0.12 / density)\n list.push(note(start + i * step, p, staff))\n p += dir * (rng() < 0.18 ? 2 : 1)\n if (p >= 10 || p <= -2) {\n dir = -dir\n p = clamp(p, -2, 10)\n }\n }\n // A run thinned down to one note is drawn as a lone quarter.\n addStem(staff, list, step <= 0.25 ? 2 : 1)\n if (list.length >= 4 && rng() < 0.45) {\n slurs.push({ staff, first: list[0]!, last: list[list.length - 1]! })\n }\n }\n\n const arpeggio = (\n staff: Staff,\n start: number,\n root: number,\n step: number\n ) => {\n const shape = [0, 2, 4, 7, 4, 2, 0, -1]\n const list = shape.map((offset, i) =>\n note(start + i * step, root + offset, staff)\n )\n addStem(staff, list, step <= 0.25 ? 2 : 1)\n }\n\n const chord = (staff: Staff, t: number, root: number, hollow: boolean) => {\n const list = [\n note(t, root, staff, hollow),\n note(t, root + 2, staff, hollow),\n ]\n if (rng() < 0.7) list.push(note(t, root + 4, staff, hollow))\n addStem(staff, list, 0, true)\n }\n\n let t = 0\n while (t < LOOP_END) {\n const room = LOOP_END - t\n const kind = rng()\n const lead: Staff = rng() < 0.55 ? 0 : 1\n const other: Staff = lead === 0 ? 1 : 0\n const phraseStart = t\n\n if (kind < 0.5) {\n const count = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.35 * density ? 0.25 : 0.5\n const length = count * step\n if (length > room) break\n run(lead, t, Math.floor(rng() * 7) + 1, count, step)\n if (rng() < 0.55 * density) {\n chord(other, t, 1 + Math.floor(rng() * 4), length >= 2 && rng() < 0.5)\n }\n t += length\n } else if (kind < 0.72) {\n if (room < 2) break\n arpeggio(lead, t, 1 + Math.floor(rng() * 4), 0.25)\n if (rng() < 0.7 * density)\n run(other, t, Math.floor(rng() * 4) + 1, 4, 0.5)\n t += 2\n } else if (kind < 0.88) {\n const hollow = rng() < 0.45\n const length = hollow ? 2 : 1\n if (length > room) break\n chord(0, t, 2 + Math.floor(rng() * 4), hollow)\n chord(1, t, Math.floor(rng() * 4), hollow)\n t += length\n } else {\n // A rest. Sparser scores rest longer.\n t += density < 0.8 ? 1 : 0.5\n continue\n }\n\n if (rng() < 0.2)\n marks.push({\n t: phraseStart,\n text: DYNAMICS[Math.floor(rng() * DYNAMICS.length)]!,\n })\n if (rng() < 0.14) {\n const dur = Math.min(2 + Math.floor(rng() * 3), LOOP_END - phraseStart)\n if (dur >= 1) hairpins.push({ t: phraseStart, dur, opening: rng() < 0.5 })\n }\n }\n\n return { notes, stems, marks, hairpins, slurs }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n return Array.from({ length: 90 }, () => ({\n x: rng(),\n y: rng(),\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n }))\n}\n\n/** A soft round glow in `rgb`, drawn once and stamped with `drawImage`. */\nfunction makeGlowSprite(rgb: Rgb) {\n const size = 64\n const sprite = document.createElement(\"canvas\")\n sprite.width = size\n sprite.height = size\n const g = sprite.getContext(\"2d\")\n if (!g) return sprite\n const grad = g.createRadialGradient(\n size / 2,\n size / 2,\n 0,\n size / 2,\n size / 2,\n size / 2\n )\n grad.addColorStop(0, rgba(rgb, 1))\n grad.addColorStop(0.25, rgba(rgb, 0.45))\n grad.addColorStop(0.6, rgba(rgb, 0.1))\n grad.addColorStop(1, rgba(rgb, 0))\n g.fillStyle = grad\n g.fillRect(0, 0, size, size)\n return sprite\n}\n\n/** Brightness at the playhead: ramps in just before, decays after (phosphor persistence). */\nfunction litAt(d: number) {\n if (d > 0.3) return 0\n if (d >= 0) {\n const k = 1 - d / 0.3\n return k * k\n }\n return Math.exp(d * 2.4)\n}\n\n/**\n * How the score plane sits in front of the camera. Plane coordinates are\n * the unrotated canvas pixels; it turns about the playhead, which is also\n * where perspective converges.\n */\ntype View = {\n /** `rotateX · rotateY · rotateZ`, row-major, CSS axes (y down, z toward you). */\n m: number[]\n ox: number\n oy: number\n /** Camera distance. */\n cam: number\n /** No depth variation across the plane, so nothing foreshortens or fogs. */\n depthless: boolean\n /** Bounds of the plane that are on screen and not lost in the distance. */\n x0: number\n x1: number\n y0: number\n y1: number\n}\n\nfunction makeView(\n w: number,\n h: number,\n rotateX: number,\n rotateY: number,\n rotateZ: number\n): View {\n const rad = Math.PI / 180\n const [cx, sx] = [Math.cos(rotateX * rad), Math.sin(rotateX * rad)]\n const [cy, sy] = [Math.cos(rotateY * rad), Math.sin(rotateY * rad)]\n const [cz, sz] = [Math.cos(rotateZ * rad), Math.sin(rotateZ * rad)]\n // Rx · Ry · Rz, the order CSS applies `rotateX() rotateY() rotateZ()`.\n const m = [\n cy * cz,\n -cy * sz,\n sy,\n sx * sy * cz + cx * sz,\n -sx * sy * sz + cx * cz,\n -sx * cy,\n -cx * sy * cz + sx * sz,\n cx * sy * sz + sx * cz,\n cx * cy,\n ]\n const ox = w / 2\n const oy = h * PLAYHEAD\n const cam = Math.max(w, h) * PERSPECTIVE\n const view: View = {\n m,\n ox,\n oy,\n cam,\n depthless: Math.abs(m[6]!) + Math.abs(m[7]!) < 1e-6,\n x0: 0,\n x1: w,\n y0: 0,\n y1: h,\n }\n if (rotateX === 0 && rotateY === 0 && rotateZ === 0) return view\n\n // The screen, less whatever lies beyond FAR_SCALE, traced back onto the\n // plane. Along a ray through screen point (u, v) the plane is hit at\n // t = m22·cam / (m22·cam − m02·u − m12·v), with depth scale 1 / t, so\n // \"not too far\" is a straight line on screen and clips the rectangle.\n const [m02, m12, m22] = [m[2]!, m[5]!, m[8]!]\n const far = (u: number, v: number) =>\n m22 * cam * (1 - FAR_SCALE) - m02 * u - m12 * v\n const corners: [number, number][] = [\n [-ox, -oy],\n [w - ox, -oy],\n [w - ox, h - oy],\n [-ox, h - oy],\n ]\n const kept: [number, number][] = []\n corners.forEach((a, i) => {\n const b = corners[(i + 1) % corners.length]!\n const fa = far(a[0], a[1])\n const fb = far(b[0], b[1])\n if (fa >= 0) kept.push(a)\n if (fa >= 0 !== fb >= 0) {\n const k = fa / (fa - fb)\n kept.push([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k])\n }\n })\n\n view.x0 = view.y0 = Infinity\n view.x1 = view.y1 = -Infinity\n for (const [u, v] of kept) {\n const t = (m22 * cam) / (m22 * cam - m02 * u - m12 * v)\n const [wx, wy, wz] = [t * u, t * v, cam - t * cam]\n const px = m[0]! * wx + m[3]! * wy + m[6]! * wz + ox\n const py = m[1]! * wx + m[4]! * wy + m[7]! * wz + oy\n view.x0 = Math.min(view.x0, px)\n view.x1 = Math.max(view.x1, px)\n view.y0 = Math.min(view.y0, py)\n view.y1 = Math.max(view.y1, py)\n }\n return view\n}\n\n/** Fades the plane out as it recedes toward `FAR_SCALE`. */\nfunction fogOf(scale: number) {\n const k = clamp((scale - FAR_SCALE) / FOG_RANGE, 0, 1)\n return k * k * (3 - 2 * k)\n}\n\n/**\n * Vertical phosphor sheet music: a grand staff scrolls down toward a\n * playhead, each note blooms and flares as it plays, then fades with a\n * phosphor afterglow. Canvas 2D; pauses off-screen and in hidden tabs, and\n * holds a still frame under `prefers-reduced-motion`.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let previous: Score | null = null\n let rebuildAge = REBUILD_FADE\n let dust = makeDust(seedOf(options.seed))\n\n let dark = resolveDark(themeOf(options.theme))\n let rgb: Rgb = [0, 0, 0]\n let glowSprite = document.createElement(\"canvas\")\n const recolor = () => {\n rgb = parseColor(\n ctx,\n resolveColor(options.color, dark),\n dark ? DARK_COLOR : LIGHT_COLOR\n )\n glowSprite = makeGlowSprite(rgb)\n }\n recolor()\n options.onThemeChange?.(dark)\n\n const flares: Flare[] = []\n const size = { w: 0, h: 0, dpr: 1 }\n let reduce = false\n let onScreen = true\n let running = true\n let raf = 0\n let lastFrame = 0\n // Start a few bars in, so the first frame already has notes at the playhead.\n let beat = 8\n let clock = 0\n let view = makeView(1, 1, 0, 0, 0)\n\n /** Sizes the canvas to its parent; true when the backing store changed. */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w === size.w && h === size.h && dpr === size.dpr) return false\n size.w = w\n size.h = h\n size.dpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n wake()\n return true\n }\n\n const shouldAnimate = () =>\n running && onScreen && !reduce && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (!running || raf) return\n lastFrame = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n recolor()\n options.onThemeChange?.(dark)\n wake()\n }\n\n // Resizing cleared the canvas; draw now, or it shows blank for a frame.\n const ro = new ResizeObserver(() => {\n if (resize() && running) draw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n wake()\n }\n reduce = mqReduce.matches\n mqReduce.addEventListener(\"change\", onReduce)\n\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n function tick(now: number) {\n raf = 0\n if (!running) return\n if (Math.min(window.devicePixelRatio || 1, 2) !== size.dpr) resize()\n\n const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000))\n lastFrame = now\n const animate = shouldAnimate()\n const before = beat\n if (animate) {\n clock += dt\n beat = (beat + dt * speedOf(options.speed)) % LOOP_BEATS\n rebuildAge += dt\n spawnFlares(before, beat)\n } else {\n rebuildAge = REBUILD_FADE\n flares.length = 0\n }\n for (let i = flares.length - 1; i >= 0; i--) {\n flares[i]!.age += dt\n if (flares[i]!.age >= FLARE_LIFE) flares.splice(i, 1)\n }\n\n draw()\n // A pixel-ratio change above resized, and its wake may have queued the\n // next frame already; a second request would run two loops.\n if (animate && !raf) raf = requestAnimationFrame(tick)\n }\n\n /** Every note whose time was crossed since the last frame flares once; no frame can skip one. */\n function spawnFlares(from: number, to: number) {\n const span = wrapDelta(to, from)\n if (span <= 0) return\n for (const n of score.notes) {\n const d = wrapDelta(n.t, from)\n if (d > 0 && d <= span) {\n if (flares.length >= MAX_FLARES) flares.shift()\n flares.push({ staff: n.staff, pitch: n.pitch, age: 0 })\n }\n }\n }\n\n function draw() {\n const { w, h, dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.globalCompositeOperation = \"source-over\"\n ctx!.globalAlpha = 1\n ctx!.clearRect(0, 0, w, h)\n if (w < 2 || h < 2) return\n\n view = makeView(\n w,\n h,\n tiltOf(options.rotateX),\n tiltOf(options.rotateY),\n spinOf(options.rotateZ)\n )\n const swayOn = options.sway !== false && !reduce\n const cam = {\n x: swayOn ? Math.cos(clock * 0.23) * 10 : 0,\n y: swayOn ? Math.sin(clock * 0.31) * 8 : 0,\n }\n const sp = clamp(Math.min(w, h) * 0.021, 5, 11)\n const layout = {\n sp,\n gap: clamp(w * 0.16, sp * 6.5, sp * 12),\n ppb: clamp(h * 0.15, sp * 5.2, sp * 11),\n cx: w / 2 + cam.x,\n playY: h * PLAYHEAD + cam.y,\n }\n const bloom = glowOf(options.glow) / 50\n\n // Dust drifts in screen space, beneath everything.\n for (const d of dust) {\n const px = (d.x + clock * 0.01 * d.z) % 1\n const py = (d.y + beat * 0.004 * (0.3 + d.z)) % 1\n ctx!.fillStyle = rgba(rgb, dark ? d.a : d.a * 0.35)\n ctx!.fillRect(px * w, py * h, d.s, d.s)\n }\n\n drawStaves(layout)\n\n const fade = previous ? clamp(rebuildAge / REBUILD_FADE, 0, 1) : 1\n if (previous && fade >= 1) previous = null\n if (previous) drawScore(previous, layout, 1 - fade, 0)\n drawScore(score, layout, fade, bloom)\n\n drawPlayhead(layout, bloom)\n drawFlares(layout, bloom)\n drawEdgeFade(w, h)\n }\n\n type Layout = {\n sp: number\n gap: number\n ppb: number\n cx: number\n playY: number\n }\n\n const xOf = (l: Layout, staff: Staff, pitch: number) =>\n l.cx + (staff === 0 ? -l.gap : l.gap) + (pitch - 4) * (l.sp / 2)\n const yOf = (l: Layout, d: number) => l.playY - d * l.ppb\n\n /**\n * Every beat offset at which something at time `t`, lasting `len` beats,\n * is in view. Usually one; a steep tilt can see several loops ahead.\n */\n function spots(l: Layout, t: number, len = 0) {\n const minD = (l.playY - view.y1 - l.ppb) / l.ppb\n const maxD = (l.playY - view.y0 + l.ppb) / l.ppb\n const first = wrapDelta(t, beat)\n const out: number[] = []\n const from = Math.ceil((minD - len - first) / LOOP_BEATS)\n const to = Math.floor((maxD - first) / LOOP_BEATS)\n for (let k = from; k <= to; k++) out.push(first + k * LOOP_BEATS)\n return out\n }\n\n /**\n * Plane point → screen `[x, y, scale]`, where scale is how much the\n * perspective enlarges it there. Scale is `0` behind the camera.\n */\n function project(x: number, y: number): [number, number, number] {\n const { m, ox, oy, cam } = view\n const px = x - ox\n const py = y - oy\n const z = m[6]! * px + m[7]! * py\n if (cam - z < cam * 0.02) return [0, 0, 0]\n const s = cam / (cam - z)\n return [\n ox + (m[0]! * px + m[1]! * py) * s,\n oy + (m[3]! * px + m[4]! * py) * s,\n s,\n ]\n }\n\n /**\n * Moves the canvas into the plane at `(x, y)`, so a notehead, glyph or\n * glow drawn around the origin tilts and foreshortens with the score.\n * Returns the distance fog there; `leave()` restores screen space.\n */\n function enter(x: number, y: number) {\n const { m, ox, oy, cam } = view\n const { dpr } = size\n const px = x - ox\n const py = y - oy\n const qx = m[0]! * px + m[1]! * py\n const qy = m[3]! * px + m[4]! * py\n const z = m[6]! * px + m[7]! * py\n if (cam - z < cam * 0.02) return 0\n const s = cam / (cam - z)\n // Jacobian of the projection: how a unit step along the plane lands on screen.\n const k = (s * s) / cam\n ctx!.setTransform(\n dpr * (s * m[0]! + qx * k * m[6]!),\n dpr * (s * m[3]! + qy * k * m[6]!),\n dpr * (s * m[1]! + qx * k * m[7]!),\n dpr * (s * m[4]! + qy * k * m[7]!),\n dpr * (ox + qx * s),\n dpr * (oy + qy * s)\n )\n return fogOf(s)\n }\n\n function leave() {\n const { dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.globalAlpha = 1\n }\n\n /** Plane stroke width; `line` and `polyline` scale it with depth. */\n let lw = 1\n\n /**\n * A straight stroke on the plane. Long ones pass `segments` so the fog\n * can thin them as they recede.\n */\n function line(x0: number, y0: number, x1: number, y1: number, segments = 1) {\n const n = view.depthless ? 1 : segments\n for (let i = 0; i < n; i++) {\n const a = i / n\n const b = (i + 1) / n\n polyline([\n [x0 + (x1 - x0) * a, y0 + (y1 - y0) * a],\n [x0 + (x1 - x0) * b, y0 + (y1 - y0) * b],\n ])\n }\n }\n\n function polyline(points: [number, number][]) {\n let scale = 0\n ctx!.beginPath()\n for (const [i, [x, y]] of points.entries()) {\n const p = project(x, y)\n if (p[2] <= 0) return\n scale += p[2]\n if (i === 0) ctx!.moveTo(p[0], p[1])\n else ctx!.lineTo(p[0], p[1])\n }\n scale /= points.length\n const fog = fogOf(scale)\n if (fog <= 0) return\n ctx!.globalAlpha = fog\n ctx!.lineWidth = lw * scale\n ctx!.stroke()\n ctx!.globalAlpha = 1\n }\n\n /** A quadratic curve on the plane; its control point is projected too. */\n function curve(\n x0: number,\n y0: number,\n cx: number,\n cy: number,\n x1: number,\n y1: number\n ) {\n const a = project(x0, y0)\n const c = project(cx, cy)\n const b = project(x1, y1)\n if (a[2] <= 0 || b[2] <= 0 || c[2] <= 0) return\n const scale = (a[2] + b[2]) / 2\n const fog = fogOf(scale)\n if (fog <= 0) return\n ctx!.globalAlpha = fog\n ctx!.lineWidth = lw * scale\n ctx!.beginPath()\n ctx!.moveTo(a[0], a[1])\n ctx!.quadraticCurveTo(c[0], c[1], b[0], b[1])\n ctx!.stroke()\n ctx!.globalAlpha = 1\n }\n\n /** The glow sprite, stretched to `rx` × `ry` around `(x, y)` on the plane. */\n function glow(x: number, y: number, rx: number, ry: number, alpha: number) {\n const fog = enter(x, y)\n if (fog > 0) {\n ctx!.globalAlpha = alpha * fog\n ctx!.drawImage(glowSprite, -rx, -ry, rx * 2, ry * 2)\n }\n leave()\n }\n\n function drawStaves(l: Layout) {\n ctx!.lineCap = \"butt\"\n lw = 1\n ctx!.strokeStyle = rgba(rgb, dark ? 0.2 : 0.24)\n for (const staff of [0, 1] as const) {\n for (let p = 0; p <= 8; p += 2) {\n const x = Math.round(xOf(l, staff, p)) + 0.5\n line(x, view.y0, x, view.y1, 16)\n }\n }\n // Bar lines every four beats, through both staves like a grand staff.\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n for (const d of spots(l, bar)) {\n const y = yOf(l, d)\n ctx!.strokeStyle = rgba(rgb, (dark ? 0.16 : 0.2) + litAt(d) * 0.3)\n line(xOf(l, 0, 0), y, xOf(l, 1, 8), y)\n }\n }\n }\n\n function drawScore(s: Score, l: Layout, opacity: number, bloom: number) {\n if (opacity <= 0) return\n const base = (d: number) =>\n d >= 0 ? (dark ? 0.46 : 0.58) : dark ? 0.22 : 0.3\n const alphaAt = (d: number) => {\n const b = base(d)\n return (b + (1 - b) * litAt(d)) * opacity\n }\n const sp = l.sp\n const rx = sp * 0.5\n const ry = sp * 0.64\n\n ctx!.lineCap = \"butt\"\n\n // Dynamics and hairpins share the space between the staves.\n ctx!.font = `italic ${Math.round(clamp(sp * 1.3, 10, 17))}px Georgia, \"Times New Roman\", serif`\n ctx!.textAlign = \"center\"\n ctx!.textBaseline = \"middle\"\n for (const mark of s.marks) {\n for (const d of spots(l, mark.t)) {\n const fog = enter(l.cx, yOf(l, d) + sp * 0.9)\n if (fog > 0) {\n ctx!.globalAlpha = fog\n ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85)\n ctx!.fillText(mark.text, 0, 0)\n }\n leave()\n }\n }\n lw = 1\n for (const pin of s.hairpins) {\n for (const d0 of spots(l, pin.t, pin.dur)) {\n const d1 = d0 + pin.dur\n const narrow = pin.opening ? d0 : d1\n const wide = pin.opening ? d1 : d0\n const half = sp * 0.9\n ctx!.strokeStyle = rgba(\n rgb,\n alphaAt(Math.min(Math.abs(d0), Math.abs(d1))) * 0.6\n )\n polyline([\n [l.cx - half, yOf(l, wide)],\n [l.cx, yOf(l, narrow)],\n [l.cx + half, yOf(l, wide)],\n ])\n }\n }\n\n // Slurs curve on the inner side, opposite the stems.\n lw = Math.max(1, sp * 0.1)\n for (const slur of s.slurs) {\n const len = slur.last.t - slur.first.t\n for (const d0 of spots(l, slur.first.t, len)) {\n const d1 = d0 + len\n const inward = slur.staff === 0 ? 1 : -1\n const y0 = yOf(l, d0)\n const y1 = yOf(l, d1)\n const x0 = xOf(l, slur.staff, slur.first.pitch + inward * 2)\n const x1 = xOf(l, slur.staff, slur.last.pitch + inward * 2)\n const edge =\n inward > 0\n ? Math.max(slur.first.pitch, slur.last.pitch) + 3.5\n : Math.min(slur.first.pitch, slur.last.pitch) - 3.5\n ctx!.strokeStyle = rgba(\n rgb,\n alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5\n )\n curve(x0, y0, xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1)\n }\n }\n\n // Stems and beams. A stem runs along the pitch axis from the side of its\n // head (the time axis is \"across\" here), out to the beam.\n const stemW = Math.max(1, sp * 0.11)\n const side = -ry * 0.8\n for (const stem of s.stems) {\n const len = stem.t1 - stem.t0\n for (const d0 of spots(l, stem.t0, len)) {\n const d1 = d0 + len\n const out = stem.staff === 0 ? -1 : 1\n const tipX = xOf(l, stem.staff, stem.tip)\n lw = stemW\n if (stem.chord) {\n // One stem through the chord, from the innermost head out to the tip.\n const inner =\n out < 0\n ? Math.max(...stem.notes.map((n) => n.pitch))\n : Math.min(...stem.notes.map((n) => n.pitch))\n const y = yOf(l, d0) + side\n ctx!.strokeStyle = rgba(rgb, alphaAt(d0))\n line(xOf(l, stem.staff, inner), y, tipX, y)\n continue\n }\n for (const n of stem.notes) {\n const d = d0 + (n.t - stem.t0)\n const y = yOf(l, d) + side\n ctx!.strokeStyle = rgba(rgb, alphaAt(d))\n line(xOf(l, stem.staff, n.pitch), y, tipX, y)\n }\n if (stem.beams === 0) continue\n const nearest = d1 < 0 ? d1 : d0 > 0 ? d0 : 0\n ctx!.strokeStyle = rgba(rgb, alphaAt(nearest))\n lw = sp * 0.36\n const yA = yOf(l, d0) + side + stemW / 2\n const yB = yOf(l, d1) + side - stemW / 2\n const offset = sp * 0.18 * out\n line(tipX - offset, yA, tipX - offset, yB)\n if (stem.beams === 2) {\n const x2 = tipX - offset - out * sp * 0.75\n line(x2, yA, x2, yB)\n }\n }\n }\n\n // Ledger lines, parallel to the staff lines.\n lw = 1\n for (const n of s.notes) {\n if (n.pitch > -2 && n.pitch < 10) continue\n for (const d of spots(l, n.t)) {\n const y = yOf(l, d)\n ctx!.strokeStyle = rgba(rgb, alphaAt(d) * 0.7)\n const from = n.pitch < 0 ? -2 : 10\n const step = n.pitch < 0 ? -2 : 2\n for (let p = from; step < 0 ? p >= n.pitch : p <= n.pitch; p += step) {\n const x = xOf(l, n.staff, p)\n line(x, y - ry * 1.7, x, y + ry * 1.7)\n }\n }\n }\n\n // Glow under the heads that are playing.\n if (bloom > 0) {\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n for (const n of s.notes) {\n for (const d of spots(l, n.t)) {\n const lit = litAt(d)\n if (lit < 0.03) continue\n const r = sp * (1.1 + lit * 1.5) * bloom\n glow(\n xOf(l, n.staff, n.pitch),\n yOf(l, d),\n r,\n r,\n lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity\n )\n }\n }\n ctx!.globalCompositeOperation = \"source-over\"\n }\n\n // Noteheads, with accidentals just before them in reading order (below).\n ctx!.font = `${Math.round(sp * 1.5)}px Georgia, \"Times New Roman\", serif`\n for (const n of s.notes) {\n for (const d of spots(l, n.t)) {\n const fog = enter(xOf(l, n.staff, n.pitch), yOf(l, d))\n if (fog <= 0) {\n leave()\n continue\n }\n ctx!.globalAlpha = fog\n const color = rgba(rgb, alphaAt(d))\n ctx!.beginPath()\n ctx!.ellipse(0, 0, rx, ry, 0.35, 0, Math.PI * 2)\n if (n.hollow) {\n ctx!.strokeStyle = color\n ctx!.lineWidth = Math.max(1.2, sp * 0.16)\n ctx!.stroke()\n } else {\n ctx!.fillStyle = color\n ctx!.fill()\n }\n if (n.acc !== 0) {\n ctx!.fillStyle = color\n ctx!.fillText(n.acc > 0 ? \"♯\" : \"♭\", 0, ry + sp * 0.95)\n }\n leave()\n }\n }\n }\n\n function drawPlayhead(l: Layout, bloom: number) {\n const y = Math.round(l.playY) + 0.5\n if (bloom > 0) {\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n const alpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom)\n const band = l.sp * 1.4 * Math.max(0.6, bloom)\n // In slices, so the band follows the perspective across the plane.\n const slices = view.depthless ? 1 : 24\n const span = (view.x1 - view.x0) / slices\n const src = glowSprite.width / slices\n for (let i = 0; i < slices; i++) {\n const fog = enter(view.x0 + (i + 0.5) * span, y)\n if (fog > 0) {\n ctx!.globalAlpha = alpha * fog\n ctx!.drawImage(\n glowSprite,\n i * src,\n 0,\n src,\n glowSprite.height,\n -span / 2,\n -band,\n span,\n band * 2\n )\n }\n leave()\n }\n ctx!.globalCompositeOperation = \"source-over\"\n }\n ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55)\n lw = 1\n line(view.x0, y, view.x1, y, 12)\n }\n\n function drawFlares(l: Layout, bloom: number) {\n if (flares.length === 0 || bloom <= 0) return\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n for (const f of flares) {\n const k = f.age / FLARE_LIFE\n const fade = (1 - k) * (1 - k)\n const x = xOf(l, f.staff, f.pitch)\n const r = l.sp * (1.6 + k * 1.4) * bloom\n glow(x, l.playY, r, r, fade * (dark ? 0.8 : 0.4))\n // A streak along the playhead.\n const streak = l.sp * (2.5 + k * 3) * bloom\n glow(x, l.playY, streak, 1.5, fade * (dark ? 0.9 : 0.5))\n }\n ctx!.globalCompositeOperation = \"source-over\"\n }\n\n /** Top and bottom fade into the background, in the canvas, so it costs nothing and blurs nothing. */\n function drawEdgeFade(w: number, h: number) {\n const f = h * EDGE_FADE\n // Erase toward the edges rather than paint over them, so the fade\n // lands on whatever background the parent has.\n const solid = \"rgba(0,0,0,1)\"\n const clear = \"rgba(0,0,0,0)\"\n ctx!.globalCompositeOperation = \"destination-out\"\n const top = ctx!.createLinearGradient(0, 0, 0, f)\n top.addColorStop(0, solid)\n top.addColorStop(1, clear)\n ctx!.fillStyle = top\n ctx!.fillRect(0, 0, w, f)\n const bottom = ctx!.createLinearGradient(0, h - f, 0, h)\n bottom.addColorStop(0, clear)\n bottom.addColorStop(1, solid)\n ctx!.fillStyle = bottom\n ctx!.fillRect(0, h - f, w, f)\n ctx!.globalCompositeOperation = \"source-over\"\n }\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDensity = densityOf(options.density)\n const prevColor = options.color\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n if (options.color !== prevColor) recolor()\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDensity\n ) {\n // Crossfade from the old score instead of cutting.\n previous = reduce ? null : score\n rebuildAge = reduce ? REBUILD_FADE : 0\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n flares.length = 0\n }\n wake()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music: a grand staff scrolls down to a playhead,\n * each note blooms and flares as it plays, then fades with an afterglow.\n * Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n rotateX = 0,\n rotateY = 0,\n rotateZ = 0,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n rotateX,\n rotateY,\n rotateZ,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n rotateX,\n rotateY,\n rotateZ,\n theme,\n })\n }, [\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n rotateX,\n rotateY,\n rotateZ,\n theme,\n ])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/public/r/radiant-lines-svelte.json b/public/r/radiant-lines-svelte.json index 7850515..a05727c 100644 --- a/public/r/radiant-lines-svelte.json +++ b/public/r/radiant-lines-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/radiant-lines/radiant-lines.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/radiant-lines.svelte" } diff --git a/public/r/radiant-lines.json b/public/r/radiant-lines.json index 77215ee..2c4d1af 100644 --- a/public/r/radiant-lines.json +++ b/public/r/radiant-lines.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/radiant-lines/radiant-lines.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type RefObject } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type RadiantLinesOptions = {\n /** Star / streak colors */\n colors?: string[]\n /** How many stars. Default 420 */\n starCount?: number\n /**\n * How far each star jumps when warping (idle drift and scroll).\n * `1` matches the original ~60fps travel; higher values lengthen streaks.\n * Default `1`.\n */\n displacement?: number\n /**\n * Scroll container. Omit / `null` to use the window.\n * Pass the overflow scroller when the component lives inside one.\n */\n container?: HTMLElement | null\n}\n\nexport type RadiantLinesInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLORS = [\n \"#FF6B4A\",\n \"#2DD4BF\",\n \"#FBBF24\",\n \"#60A5FA\",\n \"#F472B6\",\n \"#A3E635\",\n \"#94A3B8\",\n]\n\nconst DEPTH = 1000\nconst STIFFNESS = 70\nconst DAMPING = 32\nconst MASS = 0.5\n/** Original per-frame coefficient (`2.4`) converted to per-second at 60fps. */\nconst TRAVEL_PER_SEC = 2.4 * 60\n/** Draw streaks as a 60fps step so filled heads keep a visible tail at any fps. */\nconst TRAIL_DT = 1 / 60\n\ntype Star = {\n x: number\n y: number\n z: number\n pz: number\n color: string\n}\n\nfunction paletteOf(colors?: string[]) {\n return colors && colors.length > 0 ? colors : DEFAULT_COLORS\n}\n\nfunction displacementOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return 1\n return Math.max(0, value)\n}\n\nfunction createStars(count: number, colors: string[]): Star[] {\n const stars: Star[] = []\n for (let i = 0; i < count; i++) {\n const z = Math.random() * DEPTH\n stars.push({\n x: (Math.random() - 0.5) * DEPTH,\n y: (Math.random() - 0.5) * DEPTH,\n z,\n pz: z,\n color: colors[i % colors.length]!,\n })\n }\n return stars\n}\n\n/**\n * Hyperspace starfield: colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme). Warp speed follows\n * scroll velocity (down → inward, up → outward). `displacement` scales how\n * far each star travels (and how long the filled heads' streaks get).\n * The loop pauses off screen and in hidden tabs. Under reduced motion it\n * draws a still field, redrawn only when size or options change.\n */\nexport function createRadiantLines(\n canvas: HTMLCanvasElement,\n initial: RadiantLinesOptions = {}\n): RadiantLinesInstance | null {\n let options: RadiantLinesOptions = {\n starCount: 420,\n colors: DEFAULT_COLORS,\n displacement: 1,\n container: null,\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let stars = createStars(options.starCount ?? 420, paletteOf(options.colors))\n const size = { w: 0, h: 0 }\n let speed = 0.28\n let speedVel = 0\n let speedTarget = 0.28\n let dir = -1\n let reduce = false\n let raf = 0\n let destroyed = false\n let onScreen = true\n let lastFrame = performance.now()\n let lastScrollY = 0\n let lastScrollT = performance.now()\n let attached: Window | HTMLElement | null = null\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n // Resizing clears the canvas, so a paused field needs a fresh frame.\n wake()\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n }\n wake()\n }\n\n const readScrollY = () => {\n if (options.container) return options.container.scrollTop\n return window.scrollY\n }\n\n const applyWarp = (v: number) => {\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n return\n }\n // Flipped: scroll down → inward; scroll up → outward\n if (Math.abs(v) > 2) {\n dir = v > 0 ? 1 : -1\n }\n const boost = Math.min(8, Math.abs(v) / 80)\n speedTarget = 0.22 + boost\n }\n\n const onScroll = () => {\n const now = performance.now()\n const y = readScrollY()\n const dt = now - lastScrollT\n const v = dt > 0 ? ((y - lastScrollY) / dt) * 1000 : 0\n lastScrollY = y\n lastScrollT = now\n applyWarp(v)\n }\n\n const unbindScroll = () => {\n if (!attached) return\n attached.removeEventListener(\"scroll\", onScroll)\n attached = null\n }\n\n const bindScroll = () => {\n const next: Window | HTMLElement = options.container ?? window\n if (attached === next) return\n unbindScroll()\n attached = next\n lastScrollY = readScrollY()\n lastScrollT = performance.now()\n attached.addEventListener(\"scroll\", onScroll, { passive: true })\n }\n\n bindScroll()\n\n const moving = () =>\n onScreen && !reduce && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed) return\n lastFrame = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // An arrow, not a declaration, so TypeScript keeps `ctx` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (destroyed) return\n\n const live = moving()\n // A paused frame redraws the field in place without advancing it.\n const dt = live ? Math.min(0.05, Math.max(0, (now - lastFrame) / 1000)) : 0\n lastFrame = now\n\n if (now - lastScrollT > 40 && !reduce) {\n applyWarp(0)\n }\n\n const accel =\n (STIFFNESS * (speedTarget - speed) - DAMPING * speedVel) / MASS\n speedVel += accel * dt\n speed += speedVel * dt\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n if (live) raf = requestAnimationFrame(tick)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n // Reduced motion: no travel and no streaks, just still points.\n const warp = reduce ? 0 : speed\n const focal = Math.max(w, h) * 0.5\n const colors = paletteOf(options.colors)\n const travel = warp * TRAVEL_PER_SEC * displacementOf(options.displacement)\n\n ctx.clearRect(0, 0, w, h)\n\n for (let i = 0; i < stars.length; i++) {\n const star = stars[i]!\n star.z -= dir * travel * dt\n\n if (star.z <= 1) {\n star.z = DEPTH\n star.pz = DEPTH\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else if (star.z >= DEPTH) {\n star.z = 1.5\n star.pz = 1.5\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else {\n // FPS-independent tail so filled heads keep a visible streak.\n star.pz = Math.min(\n DEPTH,\n Math.max(1.01, star.z + dir * travel * TRAIL_DT)\n )\n }\n\n const k = focal / star.z\n const x = cx + star.x * k\n const y = cy + star.y * k\n\n const pk = focal / star.pz\n const px = cx + star.x * pk\n const py = cy + star.y * pk\n\n if (\n (x < -50 && px < -50) ||\n (x > w + 50 && px > w + 50) ||\n (y < -50 && py < -50) ||\n (y > h + 50 && py > h + 50)\n ) {\n continue\n }\n\n const near = 1 - star.z / DEPTH\n const lineW = Math.max(0.8, near * (1.8 + warp * 0.12))\n const alpha = 0.35 + near * 0.65\n\n ctx.beginPath()\n ctx.moveTo(px, py)\n ctx.lineTo(x, y)\n ctx.strokeStyle = star.color\n ctx.globalAlpha = alpha\n ctx.lineWidth = lineW\n ctx.lineCap = \"round\"\n ctx.stroke()\n\n if (near > 0.45) {\n ctx.beginPath()\n ctx.arc(x, y, lineW * 0.9, 0, Math.PI * 2)\n ctx.fillStyle = star.color\n ctx.fill()\n }\n }\n\n ctx.globalAlpha = 1\n if (live) raf = requestAnimationFrame(tick)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n wake()\n\n return {\n setOptions(next) {\n const prevCount = options.starCount ?? 420\n const prevColors = paletteOf(options.colors).join(\",\")\n options = { ...options, ...next }\n const nextCount = options.starCount ?? 420\n const nextColors = paletteOf(options.colors).join(\",\")\n if (nextCount !== prevCount || nextColors !== prevColors) {\n stars = createStars(nextCount, paletteOf(options.colors))\n }\n bindScroll()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n unbindScroll()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type RadiantLinesProps = Omit & {\n className?: string\n /**\n * Scroll container. Omit to use the window.\n * Pass a ref when the component lives inside an overflow scroller.\n */\n containerRef?: RefObject\n}\n\n/**\n * Hyperspace starfield: colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme).\n */\nexport function RadiantLines({\n className,\n colors,\n starCount = 420,\n displacement = 1,\n containerRef,\n}: RadiantLinesProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createRadiantLines(canvas, {\n colors,\n starCount,\n displacement,\n container: containerRef?.current ?? null,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n starCount,\n displacement,\n container: containerRef?.current ?? null,\n })\n }, [colors, starCount, displacement, containerRef])\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type RefObject } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type RadiantLinesTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type RadiantLinesOptions = {\n /** Star / streak colors. Default follows the theme. */\n colors?: string[]\n /**\n * Palette mode when `colors` is unset. `auto` (the default) reads\n * `html.dark` / `html.light`, then `data-theme`, then\n * `prefers-color-scheme`, and keeps up with changes.\n */\n theme?: RadiantLinesTheme\n /** How many stars. Default 420 */\n starCount?: number\n /**\n * How far each star jumps when warping (idle drift and scroll).\n * `1` matches the original ~60fps travel; higher values lengthen streaks.\n * Default `1`.\n */\n displacement?: number\n /**\n * Scroll container. Omit / `null` to use the window.\n * Pass the overflow scroller when the component lives inside one.\n */\n container?: HTMLElement | null\n}\n\nexport type RadiantLinesInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Dark-page palette: bright streaks on black. */\nexport const DEFAULT_COLORS = [\n \"#FF6B4A\",\n \"#2DD4BF\",\n \"#FBBF24\",\n \"#60A5FA\",\n \"#F472B6\",\n \"#A3E635\",\n \"#94A3B8\",\n]\n\n/** Light-page palette: the same hues, deep enough to read on white. */\nexport const LIGHT_COLORS = [\n \"#E0472A\",\n \"#0E9484\",\n \"#C98A00\",\n \"#2A66D1\",\n \"#D2347F\",\n \"#5E8F0B\",\n \"#5B6B80\",\n]\n\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: RadiantLinesTheme = \"auto\"): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\n/**\n * Calls `onChange` whenever the page theme may have changed (the `html`\n * class, `data-theme` or `style`, or the system preference). Returns the\n * unsubscribe.\n */\nexport function watchTheme(onChange: () => void): () => void {\n if (typeof document === \"undefined\") return () => {}\n const mo = new MutationObserver(onChange)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", onChange)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", onChange)\n }\n}\n\nconst DEPTH = 1000\nconst STIFFNESS = 70\nconst DAMPING = 32\nconst MASS = 0.5\n/** Original per-frame coefficient (`2.4`) converted to per-second at 60fps. */\nconst TRAVEL_PER_SEC = 2.4 * 60\n/** Draw streaks as a 60fps step so filled heads keep a visible tail at any fps. */\nconst TRAIL_DT = 1 / 60\n\ntype Star = {\n x: number\n y: number\n z: number\n pz: number\n color: string\n}\n\nfunction paletteOf(colors: string[] | undefined, dark: boolean) {\n if (colors && colors.length > 0) return colors\n return dark ? DEFAULT_COLORS : LIGHT_COLORS\n}\n\nfunction displacementOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return 1\n return Math.max(0, value)\n}\n\nfunction createStars(count: number, colors: string[]): Star[] {\n const stars: Star[] = []\n for (let i = 0; i < count; i++) {\n const z = Math.random() * DEPTH\n stars.push({\n x: (Math.random() - 0.5) * DEPTH,\n y: (Math.random() - 0.5) * DEPTH,\n z,\n pz: z,\n color: colors[i % colors.length]!,\n })\n }\n return stars\n}\n\n/**\n * Hyperspace starfield: colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme). Warp speed follows\n * scroll velocity (down → inward, up → outward). `displacement` scales how\n * far each star travels (and how long the filled heads' streaks get).\n * The loop pauses off screen and in hidden tabs. Under reduced motion it\n * draws a still field, redrawn only when size or options change.\n */\nexport function createRadiantLines(\n canvas: HTMLCanvasElement,\n initial: RadiantLinesOptions = {}\n): RadiantLinesInstance | null {\n let options: RadiantLinesOptions = {\n starCount: 420,\n displacement: 1,\n container: null,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let dark = resolveDark(options.theme)\n let stars = createStars(\n options.starCount ?? 420,\n paletteOf(options.colors, dark)\n )\n\n // Theme or palette changes recolor the field in place, so the stars keep\n // flying instead of jumping to new positions.\n const recolor = () => {\n const colors = paletteOf(options.colors, dark)\n for (let i = 0; i < stars.length; i++) {\n stars[i]!.color = colors[i % colors.length]!\n }\n }\n const size = { w: 0, h: 0 }\n let speed = 0.28\n let speedVel = 0\n let speedTarget = 0.28\n let dir = -1\n let reduce = false\n let raf = 0\n let destroyed = false\n let onScreen = true\n let lastFrame = performance.now()\n let lastScrollY = 0\n let lastScrollT = performance.now()\n let attached: Window | HTMLElement | null = null\n\n /**\n * Sizes the canvas to its parent; true when the backing store changed.\n * Assigning the same size still clears the canvas, so it only assigns on\n * a real change.\n */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n const width = Math.floor(w * dpr)\n const height = Math.floor(h * dpr)\n if (canvas.width === width && canvas.height === height) return false\n canvas.width = width\n canvas.height = height\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n wake()\n return true\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n }\n wake()\n }\n\n const readScrollY = () => {\n if (options.container) return options.container.scrollTop\n return window.scrollY\n }\n\n const applyWarp = (v: number) => {\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n return\n }\n // Flipped: scroll down → inward; scroll up → outward\n if (Math.abs(v) > 2) {\n dir = v > 0 ? 1 : -1\n }\n const boost = Math.min(8, Math.abs(v) / 80)\n speedTarget = 0.22 + boost\n }\n\n const onScroll = () => {\n const now = performance.now()\n const y = readScrollY()\n const dt = now - lastScrollT\n const v = dt > 0 ? ((y - lastScrollY) / dt) * 1000 : 0\n lastScrollY = y\n lastScrollT = now\n applyWarp(v)\n }\n\n const unbindScroll = () => {\n if (!attached) return\n attached.removeEventListener(\"scroll\", onScroll)\n attached = null\n }\n\n const bindScroll = () => {\n const next: Window | HTMLElement = options.container ?? window\n if (attached === next) return\n unbindScroll()\n attached = next\n lastScrollY = readScrollY()\n lastScrollT = performance.now()\n attached.addEventListener(\"scroll\", onScroll, { passive: true })\n }\n\n bindScroll()\n\n const moving = () =>\n onScreen && !reduce && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed) return\n lastFrame = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n /** Draws in place now, without advancing; the tick carries on if live. */\n function redraw() {\n if (destroyed) return\n cancelAnimationFrame(raf)\n raf = 0\n lastFrame = performance.now()\n tick(lastFrame)\n }\n\n // An arrow, not a declaration, so TypeScript keeps `ctx` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (destroyed) return\n\n const live = moving()\n // A paused frame redraws the field in place without advancing it.\n const dt = live ? Math.min(0.05, Math.max(0, (now - lastFrame) / 1000)) : 0\n lastFrame = now\n\n if (now - lastScrollT > 40 && !reduce) {\n applyWarp(0)\n }\n\n const accel =\n (STIFFNESS * (speedTarget - speed) - DAMPING * speedVel) / MASS\n speedVel += accel * dt\n speed += speedVel * dt\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n if (live) raf = requestAnimationFrame(tick)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n // Reduced motion: no travel and no streaks, just still points.\n const warp = reduce ? 0 : speed\n const focal = Math.max(w, h) * 0.5\n const colors = paletteOf(options.colors, dark)\n const travel = warp * TRAVEL_PER_SEC * displacementOf(options.displacement)\n\n ctx.clearRect(0, 0, w, h)\n\n for (let i = 0; i < stars.length; i++) {\n const star = stars[i]!\n star.z -= dir * travel * dt\n\n if (star.z <= 1) {\n star.z = DEPTH\n star.pz = DEPTH\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else if (star.z >= DEPTH) {\n star.z = 1.5\n star.pz = 1.5\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else {\n // FPS-independent tail so filled heads keep a visible streak.\n star.pz = Math.min(\n DEPTH,\n Math.max(1.01, star.z + dir * travel * TRAIL_DT)\n )\n }\n\n const k = focal / star.z\n const x = cx + star.x * k\n const y = cy + star.y * k\n\n const pk = focal / star.pz\n const px = cx + star.x * pk\n const py = cy + star.y * pk\n\n if (\n (x < -50 && px < -50) ||\n (x > w + 50 && px > w + 50) ||\n (y < -50 && py < -50) ||\n (y > h + 50 && py > h + 50)\n ) {\n continue\n }\n\n const near = 1 - star.z / DEPTH\n // Thin pale streaks vanish on white, so light pages get a higher\n // floor on both width and opacity.\n const lineW = Math.max(dark ? 0.8 : 1, near * (1.8 + warp * 0.12))\n const alpha = dark ? 0.35 + near * 0.65 : 0.55 + near * 0.45\n\n ctx.beginPath()\n ctx.moveTo(px, py)\n ctx.lineTo(x, y)\n ctx.strokeStyle = star.color\n ctx.globalAlpha = alpha\n ctx.lineWidth = lineW\n ctx.lineCap = \"round\"\n ctx.stroke()\n\n if (near > 0.45) {\n ctx.beginPath()\n ctx.arc(x, y, lineW * 0.9, 0, Math.PI * 2)\n ctx.fillStyle = star.color\n ctx.fill()\n }\n }\n\n ctx.globalAlpha = 1\n if (live) raf = requestAnimationFrame(tick)\n }\n\n resize()\n // Resizing cleared the canvas; draw now, or it shows blank for a frame.\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n const syncTheme = () => {\n const next = resolveDark(options.theme)\n if (next === dark) return\n dark = next\n recolor()\n wake()\n }\n const unwatchTheme = watchTheme(syncTheme)\n\n wake()\n\n return {\n setOptions(next) {\n const prevCount = options.starCount ?? 420\n const prevColors = paletteOf(options.colors, dark).join(\",\")\n options = { ...options, ...next }\n dark = resolveDark(options.theme)\n const nextCount = options.starCount ?? 420\n const nextColors = paletteOf(options.colors, dark).join(\",\")\n if (nextCount !== prevCount) {\n stars = createStars(nextCount, paletteOf(options.colors, dark))\n } else if (nextColors !== prevColors) {\n recolor()\n }\n bindScroll()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n unbindScroll()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n unwatchTheme()\n },\n }\n}\n\nexport type RadiantLinesProps = Omit & {\n className?: string\n /**\n * Scroll container. Omit to use the window.\n * Pass a ref when the component lives inside an overflow scroller.\n */\n containerRef?: RefObject\n}\n\n/**\n * Hyperspace starfield: colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme); the stock palette\n * follows the theme.\n */\nexport function RadiantLines({\n className,\n colors,\n starCount = 420,\n displacement = 1,\n theme = \"auto\",\n containerRef,\n}: RadiantLinesProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createRadiantLines(canvas, {\n colors,\n starCount,\n displacement,\n theme,\n container: containerRef?.current ?? null,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n starCount,\n displacement,\n theme,\n container: containerRef?.current ?? null,\n })\n }, [colors, starCount, displacement, theme, containerRef])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/radiant-lines.tsx" } diff --git a/public/r/registry.json b/public/r/registry.json index 8527514..20f9f55 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -137,6 +137,32 @@ ], "type": "registry:ui" }, + { + "name": "iso-lattice", + "title": "Iso Lattice", + "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", + "files": [ + { + "path": "registry/iso-lattice/iso-lattice.tsx", + "type": "registry:ui", + "target": "components/ui/iso-lattice.tsx" + } + ], + "type": "registry:ui" + }, + { + "name": "iso-lattice-svelte", + "title": "Iso Lattice", + "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", + "files": [ + { + "path": "registry/iso-lattice/iso-lattice.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/iso-lattice.svelte" + } + ], + "type": "registry:ui" + }, { "name": "live-orb", "title": "Live Orb", diff --git a/public/r/ring-tower-svelte.json b/public/r/ring-tower-svelte.json index 75d72f9..1e2b9de 100644 --- a/public/r/ring-tower-svelte.json +++ b/public/r/ring-tower-svelte.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/ring-tower/ring-tower.svelte", - "content": "\n\n\n\n\n", + "content": "\n\n\n\n\n", "type": "registry:file", "target": "src/lib/components/ui/ring-tower.svelte" } diff --git a/public/r/ring-tower.json b/public/r/ring-tower.json index fd8a444..39ba66b 100644 --- a/public/r/ring-tower.json +++ b/public/r/ring-tower.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/ring-tower/ring-tower.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n CanvasTexture,\n ClampToEdgeWrapping,\n Color,\n CylinderGeometry,\n DoubleSide,\n Group,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PerspectiveCamera,\n Raycaster,\n RepeatWrapping,\n Scene,\n SRGBColorSpace,\n Vector2,\n WebGLRenderer,\n type Texture,\n} from \"three\"\n\nexport type RingTowerTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type RingTowerOptions = {\n /**\n * Image URLs drawn side by side around every band. Cross-origin images\n * need CORS headers. Empty draws a plain grid band.\n */\n images?: string[]\n /** Number of stacked bands. Default `12`, 1 to 24. */\n rings?: number\n /** How fast the images crawl around the bands. Default `1`, 0 to 4. */\n speed?: number\n /** Drag a band to spin the tower, with inertia. Default `true`. */\n draggable?: boolean\n /**\n * How far, in degrees, the pointer turns each band. Neighbors turn the\n * opposite way. Default `24`, 0 to 90. `0` keeps every band in step.\n */\n parallax?: number\n /**\n * How far the inside of the bands fades toward the page, 0 to 1: black in\n * dark mode, white in light mode. Default `0.86`.\n */\n backface?: number\n /** `\"auto\"` follows the page. Default `\"auto\"`. */\n theme?: RingTowerTheme\n}\n\nexport type RingTowerInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_RINGS = 12\nexport const DEFAULT_SPEED = 1\nexport const DEFAULT_PARALLAX = 24\nexport const DEFAULT_BACKFACE = 0.86\n\nconst RADIUS = 4\nconst BAND_HEIGHT = 2\nconst SPACING = 3.5\nconst TILT = 0.25\nconst SEGMENTS = 100\nconst STRIP_HEIGHT = 512\nconst CRAWL = 0.012\nconst START_ANGLE = 0.5\n/** Lean cycle per ring; signs alternate so neighbors counter-turn. */\nconst PARALLAX_FACTORS = [1, -0.75, 1.25, -0.9]\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: RingTowerTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction loadImage(url: string) {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.decoding = \"async\"\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(`RingTower: failed to load ${url}`))\n img.src = url\n })\n}\n\ntype Strip = { canvas: HTMLCanvasElement; aspect: number }\n\n/** Draws the images side by side at one height on a white strip. */\nfunction stitch(images: HTMLImageElement[], maxSize: number): Strip | null {\n const sizes = images.map((img) => {\n const aspect = img.naturalWidth / Math.max(1, img.naturalHeight)\n return { img, width: STRIP_HEIGHT * aspect }\n })\n const width = sizes.reduce((sum, s) => sum + s.width, 0)\n if (width <= 0) return null\n\n const canvas = document.createElement(\"canvas\")\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const scale = Math.min(\n Math.min(window.devicePixelRatio || 1, 2),\n maxSize / width,\n maxSize / STRIP_HEIGHT\n )\n canvas.width = Math.max(1, Math.floor(width * scale))\n canvas.height = Math.max(1, Math.floor(STRIP_HEIGHT * scale))\n ctx.scale(scale, scale)\n ctx.fillStyle = \"#ffffff\"\n ctx.fillRect(0, 0, width, STRIP_HEIGHT)\n let x = 0\n for (const s of sizes) {\n ctx.drawImage(s.img, x, 0, s.width, STRIP_HEIGHT)\n x += s.width\n }\n return { canvas, aspect: width / STRIP_HEIGHT }\n}\n\n/** Plain green grid band, used when no image loads. */\nfunction gridStrip(): Strip | null {\n const canvas = document.createElement(\"canvas\")\n canvas.width = 1024\n canvas.height = 512\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const fill = ctx.createLinearGradient(0, 0, 0, 512)\n fill.addColorStop(0, \"#36C26A\")\n fill.addColorStop(1, \"#22A653\")\n ctx.fillStyle = fill\n ctx.fillRect(0, 0, 1024, 512)\n ctx.strokeStyle = \"rgba(255, 255, 255, 0.16)\"\n ctx.lineWidth = 2\n for (let i = 0; i <= 1024; i += 32) {\n ctx.beginPath()\n ctx.moveTo(i, 0)\n ctx.lineTo(i, 512)\n ctx.stroke()\n }\n for (let i = 0; i <= 512; i += 32) {\n ctx.beginPath()\n ctx.moveTo(0, i)\n ctx.lineTo(1024, i)\n ctx.stroke()\n }\n return { canvas, aspect: 2 }\n}\n\n/**\n * A tower of open, tilted cylinder bands wrapped in a strip of images, after\n * the vuemail.dev hero. Each band leans a quarter turn from the last, so the\n * stack zig-zags. The images crawl around the bands, dragging spins the\n * tower with inertia, and the pointer turns neighboring bands opposite ways.\n * The canvas is transparent; the parent paints the background, and the\n * inside of the bands fades toward black or white with the theme. Pauses\n * off-screen and in hidden tabs; holds still under `prefers-reduced-motion`.\n */\nexport function createRingTower(\n root: HTMLElement,\n initial: RingTowerOptions = {}\n): RingTowerInstance | null {\n let options: RingTowerOptions = { draggable: true, ...initial }\n\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({\n alpha: true,\n antialias: true,\n powerPreference: \"high-performance\",\n })\n } catch {\n return null\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const canvas = renderer.domElement\n canvas.style.display = \"block\"\n canvas.style.touchAction = \"pan-y\"\n root.appendChild(canvas)\n\n const scene = new Scene()\n const camera = new PerspectiveCamera(7, 1, 0.01, 1e5)\n camera.position.set(0, 0, 70)\n const tower = new Group()\n tower.rotation.set(-0.2, START_ANGLE, 0.2)\n scene.add(tower)\n\n const geometry = new CylinderGeometry(\n RADIUS,\n RADIUS,\n BAND_HEIGHT,\n SEGMENTS,\n 1,\n true\n )\n const backface = { value: DEFAULT_BACKFACE }\n let dark = resolveDark(options.theme ?? \"auto\")\n let darkMix = dark ? 1 : 0\n const backColor = { value: new Color().setScalar(1 - darkMix) }\n\n type Band = { mesh: Mesh; material: MeshBasicMaterial; texture: Texture }\n let bands: Band[] = []\n let strip: Strip | null = null\n let base: CanvasTexture | null = null\n let tiles = 1\n\n const makeMaterial = (map: Texture) => {\n const material = new MeshBasicMaterial({\n map,\n side: DoubleSide,\n toneMapped: false,\n })\n material.onBeforeCompile = (shader) => {\n shader.uniforms.uBackface = backface\n shader.uniforms.uBackColor = backColor\n shader.fragmentShader = shader.fragmentShader\n .replace(\n \"void main() {\",\n \"uniform float uBackface;\\nuniform vec3 uBackColor;\\nvoid main() {\"\n )\n .replace(\n \"#include \",\n `#include \n if (!gl_FrontFacing) {\n diffuseColor.rgb = mix(diffuseColor.rgb, uBackColor, uBackface);\n }`\n )\n }\n material.customProgramCacheKey = () => \"ring-tower\"\n return material\n }\n\n const clearBands = () => {\n for (const band of bands) {\n tower.remove(band.mesh)\n band.material.dispose()\n if (band.texture !== base) band.texture.dispose()\n }\n bands = []\n }\n\n // Whole tiles around the band keep the seam invisible; the strip is then\n // cropped top and bottom to hold its proportions.\n const fit = (texture: Texture, aspect: number) => {\n const around = (2 * Math.PI * RADIUS) / BAND_HEIGHT\n const whole = Math.floor(around / aspect)\n if (whole >= 1) {\n tiles = whole\n texture.repeat.set(whole, Math.min(1, (aspect * whole) / around))\n texture.offset.y = (1 - texture.repeat.y) / 2\n } else {\n tiles = around / aspect\n texture.repeat.set(tiles, 1)\n texture.offset.y = 0\n }\n }\n\n const buildBands = () => {\n clearBands()\n if (!base) return\n const count = Math.round(numberOr(options.rings, DEFAULT_RINGS, 1, 24))\n const middle = Math.ceil(count / 2) - 1\n for (let i = 0; i < count; i++) {\n // Clones share the image source, so the strip uploads once.\n const texture = i === 0 ? base : base.clone()\n const material = makeMaterial(texture)\n const mesh = new Mesh(geometry, material)\n mesh.rotation.set(0, i * Math.PI * 0.5, TILT)\n mesh.position.set(0, (i - middle) * SPACING, 0)\n tower.add(mesh)\n bands.push({ mesh, material, texture })\n }\n }\n\n const setStrip = (next: Strip | null) => {\n clearBands()\n base?.dispose()\n base = null\n strip = next\n if (!strip) return\n const texture = new CanvasTexture(strip.canvas)\n texture.wrapS = RepeatWrapping\n texture.wrapT = ClampToEdgeWrapping\n texture.generateMipmaps = false\n texture.minFilter = LinearFilter\n texture.magFilter = LinearFilter\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy())\n fit(texture, strip.aspect)\n base = texture\n buildBands()\n wake()\n }\n\n let loadToken = 0\n let imagesKey = \"\"\n const loadImages = () => {\n const urls = (options.images ?? []).filter(\n (url) => typeof url === \"string\" && url.length > 0\n )\n const key = urls.join(\"\\n\")\n if (key === imagesKey && strip) return\n imagesKey = key\n const token = ++loadToken\n if (urls.length === 0) {\n setStrip(gridStrip())\n return\n }\n void Promise.allSettled(urls.map(loadImage)).then((results) => {\n if (token !== loadToken || destroyed) return\n const loaded = results.flatMap((r) =>\n r.status === \"fulfilled\" ? [r.value] : []\n )\n const next = loaded.length\n ? stitch(loaded, renderer.capabilities.maxTextureSize)\n : null\n setStrip(next ?? gridStrip())\n })\n }\n\n const size = { width: 0, height: 0 }\n const resize = () => {\n const { width, height } = root.getBoundingClientRect()\n size.width = width\n size.height = height\n if (width <= 0 || height <= 0) return\n renderer.setSize(width, height)\n camera.aspect = width / height\n camera.updateProjectionMatrix()\n wake()\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let crawl = 0\n let angle = START_ANGLE\n let velocity = 0\n let dragging = false\n let dragX = 0\n let pointerX = 0\n let pointerTarget = 0\n\n const visible = () => onScreen && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (!visible()) return\n const still = mqReduce.matches\n\n if (!still) {\n // In texture units, so wrapping at 1 never shows a jump.\n crawl += dt * CRAWL * tiles * numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n crawl %= 1\n }\n if (!dragging) {\n if (still) velocity = 0\n angle += velocity * dt\n velocity *= Math.pow(0.95, dt * 60)\n if (Math.abs(velocity) < 1e-4) velocity = 0\n }\n tower.rotation.y = angle\n\n const ease = 1 - Math.exp(-dt * 4)\n const target = still ? 0 : pointerTarget\n pointerX += (target - pointerX) * ease\n const reach =\n (numberOr(options.parallax, DEFAULT_PARALLAX, 0, 90) / 360) * tiles\n bands.forEach((band, i) => {\n const factor = PARALLAX_FACTORS[i % PARALLAX_FACTORS.length]!\n band.texture.offset.x = crawl + pointerX * reach * factor\n })\n\n darkMix += ((dark ? 1 : 0) - darkMix) * (1 - Math.exp(-dt * 6))\n if (Math.abs((dark ? 1 : 0) - darkMix) < 1e-3) darkMix = dark ? 1 : 0\n backColor.value.setScalar(1 - darkMix)\n\n if (size.width > 0 && size.height > 0) renderer.render(scene, camera)\n\n const moving =\n !still ||\n dragging ||\n velocity !== 0 ||\n Math.abs(target - pointerX) > 1e-4 ||\n darkMix !== (dark ? 1 : 0)\n if (moving) raf = requestAnimationFrame(tick)\n }\n\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const hits = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return false\n ndc.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -((e.clientY - rect.top) / rect.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n return raycaster.intersectObjects(tower.children, false).length > 0\n }\n\n const setCursor = (value: string) => {\n if (canvas.style.cursor !== value) canvas.style.cursor = value\n }\n\n const onWindowMove = (e: PointerEvent) => {\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pointerTarget = inside ? clamp(x * 2 - 1, -1, 1) : 0\n wake()\n }\n window.addEventListener(\"pointermove\", onWindowMove, { passive: true })\n\n const onDown = (e: PointerEvent) => {\n if (options.draggable === false || !hits(e)) return\n dragging = true\n dragX = e.clientX\n velocity = 0\n canvas.setPointerCapture(e.pointerId)\n setCursor(\"grabbing\")\n wake()\n }\n const onMove = (e: PointerEvent) => {\n if (dragging) {\n const delta =\n ((e.clientX - dragX) / Math.max(1, size.width)) * Math.PI * 2\n angle += delta\n velocity = delta * 60\n dragX = e.clientX\n return\n }\n setCursor(options.draggable !== false && hits(e) ? \"grab\" : \"\")\n }\n const onUp = (e: PointerEvent) => {\n if (!dragging) return\n dragging = false\n if (canvas.hasPointerCapture(e.pointerId)) {\n canvas.releasePointerCapture(e.pointerId)\n }\n setCursor(hits(e) ? \"grab\" : \"\")\n wake()\n }\n const onLeave = () => {\n if (!dragging) setCursor(\"\")\n }\n canvas.addEventListener(\"pointerdown\", onDown)\n canvas.addEventListener(\"pointermove\", onMove)\n canvas.addEventListener(\"pointerup\", onUp)\n canvas.addEventListener(\"pointercancel\", onUp)\n canvas.addEventListener(\"pointerleave\", onLeave)\n\n const ro = new ResizeObserver(resize)\n ro.observe(root)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(root)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n // three restores its own state; the loop just has to stop and resume.\n const onLost = () => {\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed) return\n lost = false\n if (base) base.needsUpdate = true\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const applyBackface = () => {\n backface.value = numberOr(options.backface, DEFAULT_BACKFACE, 0, 1)\n }\n\n applyBackface()\n resize()\n loadImages()\n wake()\n\n return {\n setOptions(next) {\n const prevRings = options.rings\n options = { ...options, ...next }\n applyBackface()\n if (options.draggable === false) {\n dragging = false\n setCursor(\"\")\n }\n if (options.rings !== prevRings) buildBands()\n loadImages()\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onWindowMove)\n canvas.removeEventListener(\"pointerdown\", onDown)\n canvas.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"pointerup\", onUp)\n canvas.removeEventListener(\"pointercancel\", onUp)\n canvas.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n clearBands()\n base?.dispose()\n geometry.dispose()\n renderer.dispose()\n renderer.forceContextLoss()\n canvas.remove()\n },\n }\n}\n\nexport type RingTowerProps = RingTowerOptions & {\n className?: string\n}\n\n/**\n * A tower of tilted bands wrapped in your images. The images crawl around\n * the bands, dragging spins the tower, and the pointer turns neighboring\n * bands opposite ways. Transparent; the parent paints the background.\n * Theme-aware.\n */\nexport function RingTower({\n className,\n images,\n rings = DEFAULT_RINGS,\n speed = DEFAULT_SPEED,\n draggable = true,\n parallax = DEFAULT_PARALLAX,\n backface = DEFAULT_BACKFACE,\n theme = \"auto\",\n}: RingTowerProps) {\n const rootRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n instanceRef.current = createRingTower(root, {\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n theme,\n })\n }, [images, rings, speed, draggable, parallax, backface, theme])\n\n return (\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n CanvasTexture,\n ClampToEdgeWrapping,\n Color,\n CylinderGeometry,\n DoubleSide,\n Group,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PerspectiveCamera,\n Raycaster,\n RepeatWrapping,\n Scene,\n SRGBColorSpace,\n Vector2,\n WebGLRenderer,\n type Texture,\n} from \"three\"\n\nexport type RingTowerTheme = \"light\" | \"dark\" | \"auto\"\n\n/** One ring's own settings. Anything left out follows the tower. */\nexport type RingTowerRing = {\n /** Images for this ring alone. Default: the tower's `images`. */\n images?: string[]\n /**\n * Which way the ring leans, in degrees around the tower. Default `i * 90`,\n * a quarter turn from the ring below, so the stack zig-zags.\n */\n rotation?: number\n /** How far the ring leans, in degrees, -45 to 45. Default `14`. */\n tilt?: number\n /**\n * Degrees per second the ring turns around the tower, -180 to 180, so its\n * lean sweeps round. Default `0`.\n */\n spin?: number\n /**\n * Crawl speed for this ring, -4 to 4; negative crawls the other way.\n * Default: the tower's `speed`.\n */\n speed?: number\n /**\n * Degrees the pointer turns this ring, -90 to 90; the sign picks the\n * direction. Default: the tower's `parallax`, alternating direction.\n */\n parallax?: number\n}\n\nexport type RingTowerOptions = {\n /**\n * Image URLs drawn side by side around every band. Cross-origin images\n * need CORS headers. Empty draws a plain grid band.\n */\n images?: string[]\n /**\n * Number of stacked rings, 1 to 5. Default `5`. Or one settings object per\n * ring, from the bottom up, to set each ring's rotation, tilt, spin,\n * speed, parallax or images; the array's length is the count.\n */\n rings?: number | RingTowerRing[]\n /** How fast the images crawl around the bands. Default `1`, 0 to 4. */\n speed?: number\n /** Drag a band to spin the tower, with inertia. Default `true`. */\n draggable?: boolean\n /**\n * How far, in degrees, the pointer turns each band. Neighbors turn the\n * opposite way. Default `24`, 0 to 90. `0` keeps every band in step.\n */\n parallax?: number\n /**\n * How far the inside of the bands fades toward the page, 0 to 1: black in\n * dark mode, white in light mode. Default `0.86`.\n */\n backface?: number\n /** `\"auto\"` follows the page. Default `\"auto\"`. */\n theme?: RingTowerTheme\n}\n\nexport type RingTowerInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_RINGS = 5\nexport const MAX_RINGS = 5\nexport const DEFAULT_SPEED = 1\nexport const DEFAULT_PARALLAX = 24\nexport const DEFAULT_BACKFACE = 0.86\nexport const DEFAULT_TILT = 14\nexport const DEFAULT_SPIN = 0\n\nconst RADIUS = 4\nconst BAND_HEIGHT = 2\nconst SPACING = 3.5\nconst SEGMENTS = 100\nconst STRIP_HEIGHT = 512\nconst CRAWL = 0.012\nconst START_ANGLE = 0.5\nconst DEG = Math.PI / 180\n/** Lean cycle per ring; signs alternate so neighbors counter-turn. */\nconst PARALLAX_FACTORS = [1, -0.75, 1.25, -0.9]\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Default rotation for ring `i`: a quarter turn from the one below. */\nexport function defaultRotation(i: number) {\n return (i * 90) % 360\n}\n\n/** Default parallax for ring `i`: neighbors turn opposite ways. */\nexport function defaultParallax(i: number, parallax = DEFAULT_PARALLAX) {\n return parallax * PARALLAX_FACTORS[i % PARALLAX_FACTORS.length]!\n}\n\n/** The ring count and each ring's own settings (empty where unset). */\nfunction ringsOf(value: RingTowerOptions[\"rings\"]): RingTowerRing[] {\n if (Array.isArray(value) && value.length > 0) {\n return value\n .slice(0, MAX_RINGS)\n .map((ring) => (ring && typeof ring === \"object\" ? ring : {}))\n }\n const count = Math.round(\n numberOr(\n Array.isArray(value) ? undefined : value,\n DEFAULT_RINGS,\n 1,\n MAX_RINGS\n )\n )\n return Array.from({ length: count }, () => ({}))\n}\n\nfunction urlsOf(images: unknown): string[] {\n if (!Array.isArray(images)) return []\n return images.filter(\n (url): url is string => typeof url === \"string\" && url.length > 0\n )\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: RingTowerTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction loadImage(url: string) {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.decoding = \"async\"\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(`RingTower: failed to load ${url}`))\n img.src = url\n })\n}\n\ntype Strip = { canvas: HTMLCanvasElement; aspect: number }\n\n/** Draws the images side by side at one height on a white strip. */\nfunction stitch(images: HTMLImageElement[], maxSize: number): Strip | null {\n const sizes = images.map((img) => {\n const aspect = img.naturalWidth / Math.max(1, img.naturalHeight)\n return { img, width: STRIP_HEIGHT * aspect }\n })\n const width = sizes.reduce((sum, s) => sum + s.width, 0)\n if (width <= 0) return null\n\n const canvas = document.createElement(\"canvas\")\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const scale = Math.min(\n Math.min(window.devicePixelRatio || 1, 2),\n maxSize / width,\n maxSize / STRIP_HEIGHT\n )\n canvas.width = Math.max(1, Math.floor(width * scale))\n canvas.height = Math.max(1, Math.floor(STRIP_HEIGHT * scale))\n ctx.scale(scale, scale)\n ctx.fillStyle = \"#ffffff\"\n ctx.fillRect(0, 0, width, STRIP_HEIGHT)\n let x = 0\n for (const s of sizes) {\n ctx.drawImage(s.img, x, 0, s.width, STRIP_HEIGHT)\n x += s.width\n }\n return { canvas, aspect: width / STRIP_HEIGHT }\n}\n\n/** Plain green grid band, used when no image loads. */\nfunction gridStrip(): Strip | null {\n const canvas = document.createElement(\"canvas\")\n canvas.width = 1024\n canvas.height = 512\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const fill = ctx.createLinearGradient(0, 0, 0, 512)\n fill.addColorStop(0, \"#36C26A\")\n fill.addColorStop(1, \"#22A653\")\n ctx.fillStyle = fill\n ctx.fillRect(0, 0, 1024, 512)\n ctx.strokeStyle = \"rgba(255, 255, 255, 0.16)\"\n ctx.lineWidth = 2\n for (let i = 0; i <= 1024; i += 32) {\n ctx.beginPath()\n ctx.moveTo(i, 0)\n ctx.lineTo(i, 512)\n ctx.stroke()\n }\n for (let i = 0; i <= 512; i += 32) {\n ctx.beginPath()\n ctx.moveTo(0, i)\n ctx.lineTo(1024, i)\n ctx.stroke()\n }\n return { canvas, aspect: 2 }\n}\n\n/**\n * A tower of open, tilted cylinder bands wrapped in a strip of images, after\n * the vuemail.dev hero. Each band leans a quarter turn from the last, so the\n * stack zig-zags. The images crawl around the bands, dragging spins the\n * tower with inertia, and the pointer turns neighboring bands opposite ways.\n * The canvas is transparent; the parent paints the background, and the\n * inside of the bands fades toward black or white with the theme. Pauses\n * off-screen and in hidden tabs; holds still under `prefers-reduced-motion`.\n */\nexport function createRingTower(\n root: HTMLElement,\n initial: RingTowerOptions = {}\n): RingTowerInstance | null {\n let options: RingTowerOptions = { draggable: true, ...initial }\n\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({\n alpha: true,\n antialias: true,\n powerPreference: \"high-performance\",\n })\n } catch {\n return null\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const canvas = renderer.domElement\n canvas.style.display = \"block\"\n canvas.style.touchAction = \"pan-y\"\n root.appendChild(canvas)\n\n const scene = new Scene()\n const camera = new PerspectiveCamera(7, 1, 0.01, 1e5)\n camera.position.set(0, 0, 70)\n const tower = new Group()\n tower.rotation.set(-0.2, START_ANGLE, 0.2)\n scene.add(tower)\n\n const geometry = new CylinderGeometry(\n RADIUS,\n RADIUS,\n BAND_HEIGHT,\n SEGMENTS,\n 1,\n true\n )\n const backface = { value: DEFAULT_BACKFACE }\n let dark = resolveDark(options.theme ?? \"auto\")\n let darkMix = dark ? 1 : 0\n const backColor = { value: new Color().setScalar(1 - darkMix) }\n\n type Band = {\n index: number\n mesh: Mesh\n material: MeshBasicMaterial\n texture: Texture\n tiles: number\n }\n let bands: Band[] = []\n // One strip per distinct image list, shared by every ring that shows it.\n // `base` is null while the images load.\n type Entry = { base: CanvasTexture | null; tiles: number; token: number }\n const strips = new Map()\n let rings = ringsOf(options.rings)\n // Kept across rebuilds, so a strip arriving never snaps a ring back.\n const crawls: number[] = []\n const turns: number[] = []\n\n const makeMaterial = (map: Texture) => {\n const material = new MeshBasicMaterial({\n map,\n side: DoubleSide,\n toneMapped: false,\n })\n material.onBeforeCompile = (shader) => {\n shader.uniforms.uBackface = backface\n shader.uniforms.uBackColor = backColor\n shader.fragmentShader = shader.fragmentShader\n .replace(\n \"void main() {\",\n \"uniform float uBackface;\\nuniform vec3 uBackColor;\\nvoid main() {\"\n )\n .replace(\n \"#include \",\n `#include \n if (!gl_FrontFacing) {\n diffuseColor.rgb = mix(diffuseColor.rgb, uBackColor, uBackface);\n }`\n )\n }\n material.customProgramCacheKey = () => \"ring-tower\"\n return material\n }\n\n const clearBands = () => {\n for (const band of bands) {\n tower.remove(band.mesh)\n band.material.dispose()\n band.texture.dispose()\n }\n bands = []\n }\n\n // Whole tiles around the band keep the seam invisible; the strip is then\n // cropped top and bottom to hold its proportions. Returns the tile count.\n const fit = (texture: Texture, aspect: number) => {\n const around = (2 * Math.PI * RADIUS) / BAND_HEIGHT\n const whole = Math.floor(around / aspect)\n if (whole >= 1) {\n texture.repeat.set(whole, Math.min(1, (aspect * whole) / around))\n texture.offset.y = (1 - texture.repeat.y) / 2\n return whole\n }\n texture.repeat.set(around / aspect, 1)\n texture.offset.y = 0\n return around / aspect\n }\n\n const imagesFor = (ring: RingTowerRing) =>\n urlsOf(ring.images).length ? urlsOf(ring.images) : urlsOf(options.images)\n const keyFor = (ring: RingTowerRing) => imagesFor(ring).join(\"\\n\")\n\n const buildBands = () => {\n clearBands()\n const middle = Math.ceil(rings.length / 2) - 1\n rings.forEach((ring, i) => {\n const entry = strips.get(keyFor(ring))\n if (!entry?.base) return\n // Clones share the image source, so each strip uploads once.\n const texture = entry.base.clone()\n const material = makeMaterial(texture)\n const mesh = new Mesh(geometry, material)\n mesh.position.set(0, (i - middle) * SPACING, 0)\n tower.add(mesh)\n bands.push({ index: i, mesh, material, texture, tiles: entry.tiles })\n })\n pose()\n }\n\n const setStrip = (key: string, next: Strip | null) => {\n const entry = strips.get(key)\n if (!entry || !next) return\n const texture = new CanvasTexture(next.canvas)\n texture.wrapS = RepeatWrapping\n texture.wrapT = ClampToEdgeWrapping\n texture.generateMipmaps = false\n texture.minFilter = LinearFilter\n texture.magFilter = LinearFilter\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy())\n entry.tiles = fit(texture, next.aspect)\n entry.base = texture\n buildBands()\n wake()\n }\n\n let loadToken = 0\n let layoutKey = \"\"\n // Loads any image list a ring now needs, drops the ones none show, and\n // rebuilds the rings when their count or images change.\n const loadImages = () => {\n const needed = new Map(rings.map((ring) => [keyFor(ring), imagesFor(ring)]))\n for (const [key, entry] of strips) {\n if (needed.has(key)) continue\n entry.base?.dispose()\n strips.delete(key)\n }\n for (const [key, urls] of needed) {\n if (strips.has(key)) continue\n const token = ++loadToken\n strips.set(key, { base: null, tiles: 1, token })\n if (urls.length === 0) {\n setStrip(key, gridStrip())\n continue\n }\n void Promise.allSettled(urls.map(loadImage)).then((results) => {\n if (destroyed || strips.get(key)?.token !== token) return\n const loaded = results.flatMap((r) =>\n r.status === \"fulfilled\" ? [r.value] : []\n )\n const next = loaded.length\n ? stitch(loaded, renderer.capabilities.maxTextureSize)\n : null\n setStrip(key, next ?? gridStrip())\n })\n }\n const nextLayout = rings.map(keyFor).join(\"\\0\")\n if (nextLayout !== layoutKey) {\n layoutKey = nextLayout\n buildBands()\n }\n }\n\n // Each ring's lean, heading and spin, read live so sliders need no rebuild.\n const pose = () => {\n for (const band of bands) {\n const ring = rings[band.index] ?? {}\n const rotation = numberOr(\n ring.rotation,\n defaultRotation(band.index),\n -3600,\n 3600\n )\n const tilt = numberOr(ring.tilt, DEFAULT_TILT, -45, 45)\n band.mesh.rotation.set(\n 0,\n rotation * DEG + (turns[band.index] ?? 0),\n tilt * DEG\n )\n }\n }\n\n const size = { width: 0, height: 0 }\n const current = new Vector2()\n /**\n * Sizes the renderer to the root; true when the canvas changed. Setting\n * the same size still clears the canvas, so it only sets a real change.\n */\n const resize = () => {\n const { width, height } = root.getBoundingClientRect()\n size.width = width\n size.height = height\n if (width <= 0 || height <= 0) return false\n renderer.getSize(current)\n if (current.x === width && current.y === height) return false\n renderer.setSize(width, height)\n camera.aspect = width / height\n camera.updateProjectionMatrix()\n wake()\n return true\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let angle = START_ANGLE\n let velocity = 0\n let dragging = false\n let dragX = 0\n let pointerX = 0\n let pointerTarget = 0\n\n const visible = () => onScreen && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (!visible()) return\n const still = mqReduce.matches\n\n const towerSpeed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n const towerParallax = numberOr(options.parallax, DEFAULT_PARALLAX, 0, 90)\n let spinning = false\n if (!still) {\n for (const band of bands) {\n const ring = rings[band.index] ?? {}\n const i = band.index\n const speed = numberOr(ring.speed, towerSpeed, -4, 4)\n // In texture units, so wrapping at 1 never shows a jump.\n const crawl = (crawls[i] ?? 0) + dt * CRAWL * band.tiles * speed\n crawls[i] = crawl - Math.floor(crawl)\n const spin = numberOr(ring.spin, DEFAULT_SPIN, -180, 180)\n if (spin !== 0) {\n turns[i] = ((turns[i] ?? 0) + spin * DEG * dt) % (Math.PI * 2)\n spinning = true\n }\n }\n }\n pose()\n if (!dragging) {\n if (still) velocity = 0\n angle += velocity * dt\n velocity *= Math.pow(0.95, dt * 60)\n if (Math.abs(velocity) < 1e-4) velocity = 0\n }\n tower.rotation.y = angle\n\n const ease = 1 - Math.exp(-dt * 4)\n const target = still ? 0 : pointerTarget\n pointerX += (target - pointerX) * ease\n for (const band of bands) {\n const ring = rings[band.index] ?? {}\n const parallax = numberOr(\n ring.parallax,\n defaultParallax(band.index, towerParallax),\n -90,\n 90\n )\n band.texture.offset.x =\n (crawls[band.index] ?? 0) + (pointerX * parallax * band.tiles) / 360\n }\n\n darkMix += ((dark ? 1 : 0) - darkMix) * (1 - Math.exp(-dt * 6))\n if (Math.abs((dark ? 1 : 0) - darkMix) < 1e-3) darkMix = dark ? 1 : 0\n backColor.value.setScalar(1 - darkMix)\n\n if (size.width > 0 && size.height > 0) renderer.render(scene, camera)\n\n const moving =\n (!still &&\n (towerSpeed !== 0 || spinning || rings.some((r) => r.speed))) ||\n dragging ||\n velocity !== 0 ||\n Math.abs(target - pointerX) > 1e-4 ||\n darkMix !== (dark ? 1 : 0)\n if (moving) raf = requestAnimationFrame(tick)\n }\n\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const hits = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return false\n ndc.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -((e.clientY - rect.top) / rect.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n return raycaster.intersectObjects(tower.children, false).length > 0\n }\n\n const setCursor = (value: string) => {\n if (canvas.style.cursor !== value) canvas.style.cursor = value\n }\n\n const onWindowMove = (e: PointerEvent) => {\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pointerTarget = inside ? clamp(x * 2 - 1, -1, 1) : 0\n wake()\n }\n window.addEventListener(\"pointermove\", onWindowMove, { passive: true })\n\n const onDown = (e: PointerEvent) => {\n if (options.draggable === false || !hits(e)) return\n dragging = true\n dragX = e.clientX\n velocity = 0\n canvas.setPointerCapture(e.pointerId)\n setCursor(\"grabbing\")\n wake()\n }\n const onMove = (e: PointerEvent) => {\n if (dragging) {\n const delta =\n ((e.clientX - dragX) / Math.max(1, size.width)) * Math.PI * 2\n angle += delta\n velocity = delta * 60\n dragX = e.clientX\n return\n }\n setCursor(options.draggable !== false && hits(e) ? \"grab\" : \"\")\n }\n const onUp = (e: PointerEvent) => {\n if (!dragging) return\n dragging = false\n if (canvas.hasPointerCapture(e.pointerId)) {\n canvas.releasePointerCapture(e.pointerId)\n }\n setCursor(hits(e) ? \"grab\" : \"\")\n wake()\n }\n const onLeave = () => {\n if (!dragging) setCursor(\"\")\n }\n canvas.addEventListener(\"pointerdown\", onDown)\n canvas.addEventListener(\"pointermove\", onMove)\n canvas.addEventListener(\"pointerup\", onUp)\n canvas.addEventListener(\"pointercancel\", onUp)\n canvas.addEventListener(\"pointerleave\", onLeave)\n\n // Resizing cleared the canvas; draw the current pose now, or it shows\n // blank for a frame.\n const ro = new ResizeObserver(() => {\n if (resize() && !destroyed && !lost) renderer.render(scene, camera)\n })\n ro.observe(root)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(root)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n // three restores its own state; the loop just has to stop and resume.\n const onLost = () => {\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed) return\n lost = false\n for (const { base } of strips.values()) if (base) base.needsUpdate = true\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const applyBackface = () => {\n backface.value = numberOr(options.backface, DEFAULT_BACKFACE, 0, 1)\n }\n\n applyBackface()\n resize()\n loadImages()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n rings = ringsOf(options.rings)\n applyBackface()\n if (options.draggable === false) {\n dragging = false\n setCursor(\"\")\n }\n loadImages()\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onWindowMove)\n canvas.removeEventListener(\"pointerdown\", onDown)\n canvas.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"pointerup\", onUp)\n canvas.removeEventListener(\"pointercancel\", onUp)\n canvas.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n clearBands()\n for (const { base } of strips.values()) base?.dispose()\n strips.clear()\n geometry.dispose()\n renderer.dispose()\n renderer.forceContextLoss()\n canvas.remove()\n },\n }\n}\n\nexport type RingTowerProps = RingTowerOptions & {\n className?: string\n}\n\n/**\n * A tower of tilted bands wrapped in your images. The images crawl around\n * the bands, dragging spins the tower, and the pointer turns neighboring\n * bands opposite ways. Pass `rings` an array to set each ring's rotation,\n * tilt, spin, speed, parallax or images. Transparent; the parent paints the\n * background. Theme-aware.\n */\nexport function RingTower({\n className,\n images,\n rings = DEFAULT_RINGS,\n speed = DEFAULT_SPEED,\n draggable = true,\n parallax = DEFAULT_PARALLAX,\n backface = DEFAULT_BACKFACE,\n theme = \"auto\",\n}: RingTowerProps) {\n const rootRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n instanceRef.current = createRingTower(root, {\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n theme,\n })\n }, [images, rings, speed, draggable, parallax, backface, theme])\n\n return (\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ring-tower.tsx" } diff --git a/public/r/shader-anime-fire-svelte.json b/public/r/shader-anime-fire-svelte.json index 4c350cc..3599f9e 100644 --- a/public/r/shader-anime-fire-svelte.json +++ b/public/r/shader-anime-fire-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/shader-anime-fire/shader-anime-fire.svelte", - "content": "\n\n\n\n\n \n \n\n", + "content": "\n\n\n\n\n \n \n\n", "type": "registry:file", "target": "src/lib/components/ui/shader-anime-fire.svelte" } diff --git a/public/r/shader-anime-fire.json b/public/r/shader-anime-fire.json index cef362c..8b554e2 100644 --- a/public/r/shader-anime-fire.json +++ b/public/r/shader-anime-fire.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-anime-fire/shader-anime-fire.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderAnimeFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderAnimeFireOptions = {\n /**\n * Three colors, coolest to hottest: ember, flame, core. In dark mode the\n * densest heat still burns past the core toward white.\n */\n colors?: string[]\n /** How fast the flames rise. Default `0.6`. */\n speed?: number\n /** Opacity of the fire and its glow, 0–1. Default `1`. */\n intensity?: number\n /** How far the flames climb, 0–1. Default `0.45`. */\n height?: number\n /** A plume of heat reaches up toward the pointer. Default `true`. */\n interactive?: boolean\n /** Ordered Bayer pixels instead of smooth flames. Default `false`. */\n dither?: boolean\n /** Dither cell size in CSS px. Default `1`. */\n pixelSize?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so light and dark swap with the site theme.\n */\n theme?: ShaderAnimeFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderAnimeFireInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Brick, flame orange and marigold, saturated enough to hold on paper. */\nexport const LIGHT_COLORS = [\"#D8341A\", \"#F9731E\", \"#FFBA3A\"]\n/** Deep ember, flame and gold; the tonemap carries the core to white-hot. */\nexport const DARK_COLORS = [\"#B4200A\", \"#FF6512\", \"#FFC04A\"]\nconst LIGHT_BASE = \"#FCFBF9\"\nconst DARK_BASE = \"#08090C\"\n\nexport const DEFAULT_SPEED = 0.6\nexport const DEFAULT_INTENSITY = 1\nexport const DEFAULT_HEIGHT = 0.45\n\nfunction fallback(colors: string[], base: string) {\n const [ember, flame, core] = colors\n return {\n backgroundColor: base,\n backgroundImage: [\n `radial-gradient(24% 18% at 50% 100%, ${core} 0%, transparent 70%)`,\n `radial-gradient(36% 30% at 24% 100%, ${flame} 0%, transparent 72%)`,\n `radial-gradient(34% 28% at 76% 100%, ${flame} 0%, transparent 72%)`,\n `radial-gradient(90% 50% at 50% 110%, ${ember} 0%, transparent 72%)`,\n ].join(\", \"),\n } as const\n}\n\n/** Shown before the first frame and wherever WebGL isn't available. */\nexport const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE)\nexport const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE)\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_intensity;\nuniform float u_height;\nuniform float u_ignite;\nuniform float u_dark;\nuniform float u_dither;\nuniform float u_pixel;\nuniform float u_pointer;\nuniform vec2 u_mouse;\n// Ember, flame, core and the paper / ink underneath, in linear light.\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_base;\n\n// Dave Hoskins' hash: no sin(), so it stays stable on mobile GPUs.\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 3; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v / 0.875;\n}\n\n// Classic Bayer (WebGL1-safe, no bitwise ops)\nfloat bayer2(vec2 c) {\n return mod(c.x * 2.0 + c.y * 3.0, 4.0);\n}\n\nfloat bayer4(vec2 p) {\n vec2 c = mod(floor(p), 4.0);\n return bayer2(mod(c, 2.0)) * 4.0 + bayer2(floor(c * 0.5));\n}\n\nfloat bayer8(vec2 p) {\n vec2 c = mod(floor(p), 8.0);\n return (bayer4(floor(c * 0.5)) * 4.0 + bayer2(mod(c, 2.0)) + 0.5) / 64.0;\n}\n\nfloat climbOf() {\n return mix(0.18, 0.78, u_height) * u_ignite;\n}\n\n// Temperature: below 0 is air, around 1 is the white-hot bed of the fire.\nfloat temperature(vec2 uv, float aspect, float t, float plume) {\n float x = uv.x * aspect;\n float climb = max(climbOf(), 0.001);\n float y = uv.y / climb;\n\n // Slow swells along the bed, so the flame line is never flat.\n y /= mix(0.6, 1.3, fbm(vec2(x * 1.1 + 3.1, t * 0.16)));\n // Under the pointer the fire stretches up toward it.\n y /= 1.0 + plume * max(u_mouse.y / climb - 0.4, 0.3);\n\n // Turbulence rising through the flame: stretched tall so it reads as\n // tongues, and swept sideways harder the higher it climbs.\n // Few octaves on purpose: smooth, graphic tongues rather than ragged,\n // photographic ones.\n vec2 q = vec2(x * 4.4, uv.y * 1.5 - t * 1.1);\n vec2 w = vec2(\n fbm(q * vec2(0.5, 0.8) + vec2(0.0, -t * 0.3)),\n fbm(q * 0.6 + vec2(5.2, 1.3 - t * 0.25))\n );\n q += (w - 0.5) * vec2(2.4, 0.8) * (0.3 + y);\n float body = fbm(q) - 0.5;\n // Ridged: where the finer noise crosses its midpoint it peaks sharply,\n // which curls the bands into licks instead of flat stripes.\n float licks = 0.2 - abs(fbm(q * vec2(1.5, 2.0) + vec2(0.0, -t * 1.6)) - 0.5) * 2.0;\n float turb = body * 0.8 + licks * 0.45;\n\n float T = 1.0 - y * 1.1 + turb * 2.6 * min(0.5 + y, 1.2);\n // Nothing burns far above the tips, however the noise rolls.\n return T - smoothstep(1.0, 1.7, y);\n}\n\n// Flat cel bands, hottest innermost. 'hot' is the core pushed toward white.\nvec3 bands(float T, vec3 hot) {\n float w = 0.012;\n vec3 c = mix(u_c0, u_c1, smoothstep(0.3 - w, 0.3 + w, T));\n c = mix(c, u_c2, smoothstep(0.6 - w, 0.6 + w, T));\n return mix(c, hot, smoothstep(1.02 - w, 1.02 + w, T));\n}\n\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n vec3 lo = c * 12.92;\n vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(vec3(0.0031308), c));\n}\n\nvoid main() {\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float t = u_time;\n\n vec2 cell = floor(gl_FragCoord.xy / u_pixel);\n vec2 uv = gl_FragCoord.xy / u_resolution;\n if (u_dither > 0.5) uv = (cell + 0.5) * u_pixel / u_resolution;\n\n float dx = (uv.x - u_mouse.x) * aspect;\n float plume = exp(-dx * dx * 26.0) * u_pointer;\n\n float T = temperature(uv, aspect, t, plume);\n // Dither shakes the band edges by the Bayer threshold, so each boundary\n // prints as an ordered-dot blend instead of a hard line.\n float q = u_dither > 0.5 ? bayer8(cell) - 0.5 : 0.0;\n T += q * 0.14;\n float edge = u_dither > 0.5 ? 0.001 : 0.012;\n float cover = smoothstep(0.0, edge, T) * mix(0.35, 1.0, u_intensity);\n\n // The whole fire breathes a little, like a real one lighting a room.\n float flicker = 0.86 + 0.14 * noise(vec2(t * 2.3, 4.7));\n float glow = exp(-uv.y / max(climbOf() * 1.7, 0.02) * 3.4) * flicker;\n glow += plume * exp(-length(vec2(dx, uv.y - u_mouse.y)) * 5.0) * 0.5;\n glow *= u_intensity;\n if (u_dither > 0.5) glow = step(0.52 + q * 0.98, glow * 2.0) * 0.5;\n\n // On ink the core goes near white; on paper white would vanish into the\n // page, so it stops at a pale gold.\n vec3 hot = mix(u_c2, vec3(1.0), u_dark > 0.5 ? 0.75 : 0.4);\n vec3 col = mix(u_base, mix(u_c0, u_c1, 0.4), glow * (u_dark > 0.5 ? 0.08 : 0.12));\n col = mix(col, bands(T, hot), cover);\n\n vec3 srgb = linearToSrgb(col);\n if (u_dither < 0.5) srgb += (hash(gl_FragCoord.xy + fract(t)) - 0.5) * 0.006;\n gl_FragColor = vec4(clamp(srgb, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Hex → linear sRGB, so the shader can add light the way light adds. */\nfunction hexToLinear(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n const srgb = Number.isNaN(n)\n ? [0.77, 0.38, 0.18]\n : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n return srgb.map((c) =>\n c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n ) as [number, number, number]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderAnimeFireTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * A wall of cel-shaded fire along the bottom edge: turbulent tongues in\n * flat bands of heat over a warm, flickering glow. Theme-aware; pauses\n * off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`. Recovers from a lost WebGL context.\n */\nexport function createShaderAnimeFire(\n canvas: HTMLCanvasElement,\n initial: ShaderAnimeFireOptions = {}\n): ShaderAnimeFireInstance | null {\n let options: ShaderAnimeFireOptions = {\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const target = { x: 0.5, y: 0.5 }\n // The plume eases in and out, so leaving the hero never snaps.\n let pull = 0\n let pullTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n // GL resources, made by initGl at startup and again when a lost context\n // is restored.\n type Loc = WebGLUniformLocation | null\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: Loc = null\n let uTime: Loc = null\n let uIntensity: Loc = null\n let uHeight: Loc = null\n let uIgnite: Loc = null\n let uDark: Loc = null\n let uDither: Loc = null\n let uPixel: Loc = null\n let uPointer: Loc = null\n let uMouse: Loc = null\n let uStops: Loc[] = []\n let uBase: Loc = null\n\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n const linked = gl.createProgram()\n if (!linked) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n program = linked\n gl.attachShader(linked, vs)\n gl.attachShader(linked, fs)\n gl.linkProgram(linked)\n if (!gl.getProgramParameter(linked, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: program failed to link\\n\",\n gl.getProgramInfoLog(linked)\n )\n }\n gl.deleteProgram(linked)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(linked)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(linked, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const u = (name: string) => gl.getUniformLocation(linked, name)\n uResolution = u(\"u_resolution\")\n uTime = u(\"u_time\")\n uIntensity = u(\"u_intensity\")\n uHeight = u(\"u_height\")\n uIgnite = u(\"u_ignite\")\n uDark = u(\"u_dark\")\n uDither = u(\"u_dither\")\n uPixel = u(\"u_pixel\")\n uPointer = u(\"u_pointer\")\n uMouse = u(\"u_mouse\")\n uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n uBase = u(\"u_base\")\n return true\n }\n if (!initGl()) return null\n\n // Flame edges are crisp, so render a bit past 1x, but not a full 2x,\n // which would quadruple the fbm work on retina screens.\n let dpr = 1\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n dpr = Math.min(window.devicePixelRatio || 1, 1.5)\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n if (!lost) gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n\n let colorsKey = \"\"\n const syncColors = () => {\n const source = options.colors?.length\n ? options.colors\n : dark\n ? DARK_COLORS\n : LIGHT_COLORS\n const key = `${source.join()}|${dark}`\n if (key === colorsKey) return\n colorsKey = key\n uStops.forEach((loc, i) => {\n gl.uniform3fv(loc, hexToLinear(source[i % source.length]!))\n })\n gl.uniform3fv(uBase, hexToLinear(dark ? DARK_BASE : LIGHT_BASE))\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n // Set while the browser has taken the WebGL context away.\n let lost = false\n let last = 0\n let clock = 0\n // Seconds since the fire first caught; drives the ignite.\n let lit = mqReduce.matches ? 10 : 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n if (live) {\n clock += dt\n lit += dt\n } else if (mqReduce.matches) {\n lit = 10\n }\n\n mouse.x += (target.x - mouse.x) * 0.08\n mouse.y += (target.y - mouse.y) * 0.08\n pull += (pullTarget - pull) * 0.05\n\n syncColors()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n const ignite = 1 - (1 - Math.min(lit / 1.1, 1)) ** 3\n const px = Math.max(1, numberOr(options.pixelSize, 1, 1, 64)) * dpr\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, clock * speed)\n gl.uniform1f(\n uIntensity,\n numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1)\n )\n gl.uniform1f(uHeight, numberOr(options.height, DEFAULT_HEIGHT, 0, 1))\n gl.uniform1f(uIgnite, Math.max(ignite, 0.001))\n gl.uniform1f(uDark, dark ? 1 : 0)\n gl.uniform1f(uDither, options.dither ? 1 : 0)\n gl.uniform1f(uPixel, options.dither ? px : dpr)\n gl.uniform1f(uPointer, options.interactive === false ? 0 : pull)\n gl.uniform2f(uMouse, mouse.x, mouse.y)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n const settling =\n Math.abs(pull - pullTarget) > 0.002 ||\n Math.abs(target.x - mouse.x) > 0.001 ||\n Math.abs(target.y - mouse.y) > 0.001\n if (live || (settling && !mqReduce.matches)) {\n raf = requestAnimationFrame(tick)\n }\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pullTarget = inside ? 1 : 0\n if (inside) {\n target.x = x\n target.y = y\n }\n wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n // GPU resets, backgrounded tabs and the live-context cap can take the\n // context away. preventDefault asks the browser to hand it back; then\n // rebuild everything and draw again.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n colorsKey = \"\"\n resize()\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderAnimeFireProps = Omit<\n ShaderAnimeFireOptions,\n \"onThemeChange\"\n> & {\n className?: string\n}\n\n/**\n * Cel-shaded flames licking up from the bottom edge, over a warm flickering\n * glow. Heat reaches for the pointer. Theme-aware.\n */\nexport function ShaderAnimeFire({\n className,\n colors,\n speed = DEFAULT_SPEED,\n intensity = DEFAULT_INTENSITY,\n height = DEFAULT_HEIGHT,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderAnimeFireProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderAnimeFire(canvas, {\n colors,\n speed,\n intensity,\n height,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n intensity,\n height,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [colors, speed, intensity, height, interactive, dither, pixelSize, theme])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderAnimeFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderAnimeFireOptions = {\n /**\n * Three colors, coolest to hottest: ember, flame, core. In dark mode the\n * densest heat still burns past the core toward white.\n */\n colors?: string[]\n /** How fast the flames rise. Default `0.6`. */\n speed?: number\n /** Opacity of the fire and its glow, 0–1. Default `1`. */\n intensity?: number\n /** How far the flames climb, 0–1. Default `0.45`. */\n height?: number\n /** A plume of heat reaches up toward the pointer. Default `true`. */\n interactive?: boolean\n /** Ordered Bayer pixels instead of smooth flames. Default `false`. */\n dither?: boolean\n /** Dither cell size in CSS px. Default `1`. */\n pixelSize?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so light and dark swap with the site theme.\n */\n theme?: ShaderAnimeFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderAnimeFireInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Brick, flame orange and marigold, saturated enough to hold on paper. */\nexport const LIGHT_COLORS = [\"#D8341A\", \"#F9731E\", \"#FFBA3A\"]\n/** Deep ember, flame and gold; the tonemap carries the core to white-hot. */\nexport const DARK_COLORS = [\"#B4200A\", \"#FF6512\", \"#FFC04A\"]\nconst LIGHT_BASE = \"#FCFBF9\"\nconst DARK_BASE = \"#08090C\"\n\nexport const DEFAULT_SPEED = 0.6\nexport const DEFAULT_INTENSITY = 1\nexport const DEFAULT_HEIGHT = 0.45\n\nfunction fallback(colors: string[], base: string) {\n const [ember, flame, core] = colors\n return {\n backgroundColor: base,\n backgroundImage: [\n `radial-gradient(24% 18% at 50% 100%, ${core} 0%, transparent 70%)`,\n `radial-gradient(36% 30% at 24% 100%, ${flame} 0%, transparent 72%)`,\n `radial-gradient(34% 28% at 76% 100%, ${flame} 0%, transparent 72%)`,\n `radial-gradient(90% 50% at 50% 110%, ${ember} 0%, transparent 72%)`,\n ].join(\", \"),\n } as const\n}\n\n/** Shown before the first frame and wherever WebGL isn't available. */\nexport const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE)\nexport const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE)\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_intensity;\nuniform float u_height;\nuniform float u_ignite;\nuniform float u_dark;\nuniform float u_dither;\nuniform float u_pixel;\nuniform float u_pointer;\nuniform vec2 u_mouse;\n// Ember, flame, core and the paper / ink underneath, in linear light.\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_base;\n\n// Dave Hoskins' hash: no sin(), so it stays stable on mobile GPUs.\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 3; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v / 0.875;\n}\n\n// Classic Bayer (WebGL1-safe, no bitwise ops)\nfloat bayer2(vec2 c) {\n return mod(c.x * 2.0 + c.y * 3.0, 4.0);\n}\n\nfloat bayer4(vec2 p) {\n vec2 c = mod(floor(p), 4.0);\n return bayer2(mod(c, 2.0)) * 4.0 + bayer2(floor(c * 0.5));\n}\n\nfloat bayer8(vec2 p) {\n vec2 c = mod(floor(p), 8.0);\n return (bayer4(floor(c * 0.5)) * 4.0 + bayer2(mod(c, 2.0)) + 0.5) / 64.0;\n}\n\nfloat climbOf() {\n return mix(0.18, 0.78, u_height) * u_ignite;\n}\n\n// Temperature: below 0 is air, around 1 is the white-hot bed of the fire.\nfloat temperature(vec2 uv, float aspect, float t, float plume) {\n float x = uv.x * aspect;\n float climb = max(climbOf(), 0.001);\n float y = uv.y / climb;\n\n // Slow swells along the bed, so the flame line is never flat.\n y /= mix(0.6, 1.3, fbm(vec2(x * 1.1 + 3.1, t * 0.16)));\n // Under the pointer the fire stretches up toward it.\n y /= 1.0 + plume * max(u_mouse.y / climb - 0.4, 0.3);\n\n // Turbulence rising through the flame: stretched tall so it reads as\n // tongues, and swept sideways harder the higher it climbs.\n // Few octaves on purpose: smooth, graphic tongues rather than ragged,\n // photographic ones.\n vec2 q = vec2(x * 4.4, uv.y * 1.5 - t * 1.1);\n vec2 w = vec2(\n fbm(q * vec2(0.5, 0.8) + vec2(0.0, -t * 0.3)),\n fbm(q * 0.6 + vec2(5.2, 1.3 - t * 0.25))\n );\n q += (w - 0.5) * vec2(2.4, 0.8) * (0.3 + y);\n float body = fbm(q) - 0.5;\n // Ridged: where the finer noise crosses its midpoint it peaks sharply,\n // which curls the bands into licks instead of flat stripes.\n float licks = 0.2 - abs(fbm(q * vec2(1.5, 2.0) + vec2(0.0, -t * 1.6)) - 0.5) * 2.0;\n float turb = body * 0.8 + licks * 0.45;\n\n float T = 1.0 - y * 1.1 + turb * 2.6 * min(0.5 + y, 1.2);\n // Nothing burns far above the tips, however the noise rolls.\n return T - smoothstep(1.0, 1.7, y);\n}\n\n// Flat cel bands, hottest innermost. 'hot' is the core pushed toward white.\nvec3 bands(float T, vec3 hot) {\n float w = 0.012;\n vec3 c = mix(u_c0, u_c1, smoothstep(0.3 - w, 0.3 + w, T));\n c = mix(c, u_c2, smoothstep(0.6 - w, 0.6 + w, T));\n return mix(c, hot, smoothstep(1.02 - w, 1.02 + w, T));\n}\n\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n vec3 lo = c * 12.92;\n vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(vec3(0.0031308), c));\n}\n\nvoid main() {\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float t = u_time;\n\n vec2 cell = floor(gl_FragCoord.xy / u_pixel);\n vec2 uv = gl_FragCoord.xy / u_resolution;\n if (u_dither > 0.5) uv = (cell + 0.5) * u_pixel / u_resolution;\n\n float dx = (uv.x - u_mouse.x) * aspect;\n float plume = exp(-dx * dx * 26.0) * u_pointer;\n\n float T = temperature(uv, aspect, t, plume);\n // Dither shakes the band edges by the Bayer threshold, so each boundary\n // prints as an ordered-dot blend instead of a hard line.\n float q = u_dither > 0.5 ? bayer8(cell) - 0.5 : 0.0;\n T += q * 0.14;\n float edge = u_dither > 0.5 ? 0.001 : 0.012;\n float cover = smoothstep(0.0, edge, T) * mix(0.35, 1.0, u_intensity);\n\n // The whole fire breathes a little, like a real one lighting a room.\n float flicker = 0.86 + 0.14 * noise(vec2(t * 2.3, 4.7));\n float glow = exp(-uv.y / max(climbOf() * 1.7, 0.02) * 3.4) * flicker;\n glow += plume * exp(-length(vec2(dx, uv.y - u_mouse.y)) * 5.0) * 0.5;\n glow *= u_intensity;\n if (u_dither > 0.5) glow = step(0.52 + q * 0.98, glow * 2.0) * 0.5;\n\n // On ink the core goes near white; on paper white would vanish into the\n // page, so it stops at a pale gold.\n vec3 hot = mix(u_c2, vec3(1.0), u_dark > 0.5 ? 0.75 : 0.4);\n vec3 col = mix(u_base, mix(u_c0, u_c1, 0.4), glow * (u_dark > 0.5 ? 0.08 : 0.12));\n col = mix(col, bands(T, hot), cover);\n\n vec3 srgb = linearToSrgb(col);\n if (u_dither < 0.5) srgb += (hash(gl_FragCoord.xy + fract(t)) - 0.5) * 0.006;\n gl_FragColor = vec4(clamp(srgb, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Hex → linear sRGB, so the shader can add light the way light adds. */\nfunction hexToLinear(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n const srgb = Number.isNaN(n)\n ? [0.77, 0.38, 0.18]\n : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n return srgb.map((c) =>\n c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n ) as [number, number, number]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderAnimeFireTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * A wall of cel-shaded fire along the bottom edge: turbulent tongues in\n * flat bands of heat over a warm, flickering glow. Theme-aware; pauses\n * off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`. Recovers from a lost WebGL context.\n */\nexport function createShaderAnimeFire(\n canvas: HTMLCanvasElement,\n initial: ShaderAnimeFireOptions = {}\n): ShaderAnimeFireInstance | null {\n let options: ShaderAnimeFireOptions = {\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const target = { x: 0.5, y: 0.5 }\n // The plume eases in and out, so leaving the hero never snaps.\n let pull = 0\n let pullTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n // GL resources, made by initGl at startup and again when a lost context\n // is restored.\n type Loc = WebGLUniformLocation | null\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: Loc = null\n let uTime: Loc = null\n let uIntensity: Loc = null\n let uHeight: Loc = null\n let uIgnite: Loc = null\n let uDark: Loc = null\n let uDither: Loc = null\n let uPixel: Loc = null\n let uPointer: Loc = null\n let uMouse: Loc = null\n let uStops: Loc[] = []\n let uBase: Loc = null\n\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n const linked = gl.createProgram()\n if (!linked) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n program = linked\n gl.attachShader(linked, vs)\n gl.attachShader(linked, fs)\n gl.linkProgram(linked)\n if (!gl.getProgramParameter(linked, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: program failed to link\\n\",\n gl.getProgramInfoLog(linked)\n )\n }\n gl.deleteProgram(linked)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(linked)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(linked, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const u = (name: string) => gl.getUniformLocation(linked, name)\n uResolution = u(\"u_resolution\")\n uTime = u(\"u_time\")\n uIntensity = u(\"u_intensity\")\n uHeight = u(\"u_height\")\n uIgnite = u(\"u_ignite\")\n uDark = u(\"u_dark\")\n uDither = u(\"u_dither\")\n uPixel = u(\"u_pixel\")\n uPointer = u(\"u_pointer\")\n uMouse = u(\"u_mouse\")\n uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n uBase = u(\"u_base\")\n return true\n }\n if (!initGl()) return null\n\n // Flame edges are crisp, so render a bit past 1x, but not a full 2x,\n // which would quadruple the fbm work on retina screens.\n let dpr = 1\n /**\n * Sizes the canvas to its parent; true when the backing store changed.\n * Assigning the same size still clears the canvas, so it only assigns on\n * a real change.\n */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n dpr = Math.min(window.devicePixelRatio || 1, 1.5)\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n if (canvas.width === width && canvas.height === height) return false\n canvas.width = width\n canvas.height = height\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n if (!lost) gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n return true\n }\n\n let colorsKey = \"\"\n const syncColors = () => {\n const source = options.colors?.length\n ? options.colors\n : dark\n ? DARK_COLORS\n : LIGHT_COLORS\n const key = `${source.join()}|${dark}`\n if (key === colorsKey) return\n colorsKey = key\n uStops.forEach((loc, i) => {\n gl.uniform3fv(loc, hexToLinear(source[i % source.length]!))\n })\n gl.uniform3fv(uBase, hexToLinear(dark ? DARK_BASE : LIGHT_BASE))\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n // Set while the browser has taken the WebGL context away.\n let lost = false\n let last = 0\n let clock = 0\n // Seconds since the fire first caught; drives the ignite.\n let lit = mqReduce.matches ? 10 : 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n /** Draws in place now, without advancing; the tick carries on if live. */\n function redraw() {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n if (live) {\n clock += dt\n lit += dt\n } else if (mqReduce.matches) {\n lit = 10\n }\n\n mouse.x += (target.x - mouse.x) * 0.08\n mouse.y += (target.y - mouse.y) * 0.08\n pull += (pullTarget - pull) * 0.05\n\n syncColors()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n const ignite = 1 - (1 - Math.min(lit / 1.1, 1)) ** 3\n const px = Math.max(1, numberOr(options.pixelSize, 1, 1, 64)) * dpr\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, clock * speed)\n gl.uniform1f(\n uIntensity,\n numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1)\n )\n gl.uniform1f(uHeight, numberOr(options.height, DEFAULT_HEIGHT, 0, 1))\n gl.uniform1f(uIgnite, Math.max(ignite, 0.001))\n gl.uniform1f(uDark, dark ? 1 : 0)\n gl.uniform1f(uDither, options.dither ? 1 : 0)\n gl.uniform1f(uPixel, options.dither ? px : dpr)\n gl.uniform1f(uPointer, options.interactive === false ? 0 : pull)\n gl.uniform2f(uMouse, mouse.x, mouse.y)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n const settling =\n Math.abs(pull - pullTarget) > 0.002 ||\n Math.abs(target.x - mouse.x) > 0.001 ||\n Math.abs(target.y - mouse.y) > 0.001\n if (live || (settling && !mqReduce.matches)) {\n raf = requestAnimationFrame(tick)\n }\n }\n\n // Resizing cleared the canvas; draw now, or it shows blank for a frame.\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pullTarget = inside ? 1 : 0\n if (inside) {\n target.x = x\n target.y = y\n }\n wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n // GPU resets, backgrounded tabs and the live-context cap can take the\n // context away. preventDefault asks the browser to hand it back; then\n // rebuild everything and draw again.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n colorsKey = \"\"\n resize()\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderAnimeFireProps = Omit<\n ShaderAnimeFireOptions,\n \"onThemeChange\"\n> & {\n className?: string\n}\n\n/**\n * Cel-shaded flames licking up from the bottom edge, over a warm flickering\n * glow. Heat reaches for the pointer. Theme-aware.\n */\nexport function ShaderAnimeFire({\n className,\n colors,\n speed = DEFAULT_SPEED,\n intensity = DEFAULT_INTENSITY,\n height = DEFAULT_HEIGHT,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderAnimeFireProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderAnimeFire(canvas, {\n colors,\n speed,\n intensity,\n height,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n intensity,\n height,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [colors, speed, intensity, height, interactive, dither, pixelSize, theme])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/shader-anime-fire.tsx" } diff --git a/public/r/shader-gradient-svelte.json b/public/r/shader-gradient-svelte.json index 5a4f764..1cf0a35 100644 --- a/public/r/shader-gradient-svelte.json +++ b/public/r/shader-gradient-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-gradient/shader-gradient.svelte", - "content": "\n\n\n\n\n \n \n\n", + "content": "\n\n\n\n\n \n \n\n", "type": "registry:file", "target": "src/lib/components/ui/shader-gradient.svelte" } diff --git a/public/r/shader-gradient.json b/public/r/shader-gradient.json index 4f9055a..651c3a9 100644 --- a/public/r/shader-gradient.json +++ b/public/r/shader-gradient.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-gradient/shader-gradient.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderGradientTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderGradientOptions = {\n /**\n * Up to 4 colors, any CSS hex. The flow cycles through them in order and\n * back to the first, so neighbours should sit well together.\n */\n colors?: string[]\n /** Flow speed. Default `0.14`. */\n speed?: number\n /** Softness, 0–1: higher is broader, calmer bands. Default `0.7`. */\n blur?: number\n /** Color strength, 0–1; lower lets the paper / ink show through. Default `0.95`. */\n intensity?: number\n /** Film grain, 0–1. `0` turns it off. Default `0.35`. */\n grain?: number\n /** The flow swirls gently around the pointer. Default `true`. */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so light and dark swap with the site theme.\n */\n theme?: ShaderGradientTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderGradientInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Peach, butter, sky and lilac, flowing over warm paper. */\nexport const LIGHT_COLORS = [\"#F7A48B\", \"#F9D78E\", \"#9FCBF0\", \"#BBA9EE\"]\n/**\n * Blue, violet, magenta and coral over near-black ink: a narrow hue arc,\n * so every blend on the cycle stays rich instead of passing through grey.\n */\nexport const DARK_COLORS = [\"#3D52F2\", \"#9150F2\", \"#E0479F\", \"#FF7B60\"]\nconst LIGHT_BASE = \"#FBF8F4\"\nconst DARK_BASE = \"#07080B\"\n\nexport const DEFAULT_SPEED = 0.14\nexport const DEFAULT_BLUR = 0.7\nexport const DEFAULT_INTENSITY = 0.95\nexport const DEFAULT_GRAIN = 0.35\n\n/** Film grain for the CSS fallback: the same idea, as an SVG turbulence tile. */\nconst GRAIN_TILE = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.16 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")`\n\nfunction fallback(colors: string[], base: string) {\n const [a, b, c, d] = colors\n return {\n backgroundColor: base,\n backgroundImage: [\n GRAIN_TILE,\n `radial-gradient(70% 60% at 18% 22%, ${a} 0%, transparent 70%)`,\n `radial-gradient(60% 60% at 82% 30%, ${b} 0%, transparent 70%)`,\n `radial-gradient(70% 60% at 70% 88%, ${c} 0%, transparent 70%)`,\n `radial-gradient(55% 55% at 12% 86%, ${d} 0%, transparent 70%)`,\n ].join(\", \"),\n } as const\n}\n\n/** Shown before the first frame and wherever WebGL isn't available. */\nexport const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE)\nexport const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE)\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_blur;\nuniform float u_intensity;\nuniform float u_grain;\nuniform float u_grainSeed;\nuniform float u_pixel;\nuniform float u_pointer;\nuniform vec2 u_mouse;\n// Palette stops and the paper / ink underneath, in OKLab.\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_base;\n\n// Dave Hoskins' hash: no sin(), so it stays stable on mobile GPUs.\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 3; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Weight of the stop at 'at' on a cyclic 0–1 track: a smoothed hat, so any\n// two neighbouring weights always sum to one.\nfloat stop(float v, float at) {\n float d = abs(v - at);\n d = min(d, 1.0 - d);\n return smoothstep(0.0, 1.0, 1.0 - clamp(d * 4.0, 0.0, 1.0));\n}\n\nvec3 palette(float v) {\n return u_c0 * stop(v, 0.0) + u_c1 * stop(v, 0.25) +\n u_c2 * stop(v, 0.5) + u_c3 * stop(v, 0.75);\n}\n\nvec3 oklabToLinear(vec3 c) {\n float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z;\n float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z;\n float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.z;\n l = l * l * l;\n m = m * m * m;\n s = s * s * s;\n return vec3(\n 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s\n );\n}\n\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n vec3 lo = c * 12.92;\n vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(vec3(0.0031308), c));\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / u_resolution;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float scale = mix(2.0, 0.8, u_blur);\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float t = u_time;\n\n // A soft swirl around the pointer.\n vec2 d = p - vec2(u_mouse.x * aspect, u_mouse.y) * scale;\n p += vec2(-d.y, d.x) * exp(-dot(d, d) * 1.4) * 0.4 * u_pointer;\n\n // Domain warping: noise fed back through itself twice gives slow,\n // liquid bands that never read as a loop.\n vec2 q = vec2(\n fbm(p + vec2(0.0, 0.12 * t)),\n fbm(p + vec2(5.2, 1.3) - 0.1 * t)\n );\n vec2 r = vec2(\n fbm(p + 2.2 * q + vec2(1.7, 9.2) + 0.07 * t),\n fbm(p + 2.2 * q + vec2(8.3, 2.8) - 0.06 * t)\n );\n float f = fbm(p + 2.0 * r);\n\n // Where on the palette this pixel sits. The track is cyclic, so wrapping\n // past the last color flows back into the first without a seam.\n float v = fract(f * 2.4 + 0.35 * r.x + 0.02 * t);\n vec3 lab = palette(v);\n\n // Some of the paper / ink breathes through, so it isn't wall-to-wall color.\n // Ink dulls far faster than paper does, so on a dark base only a little\n // shows, and the color gets a chroma lift to glow against it.\n float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t));\n float floorMix = mix(0.82, 0.45, u_base.x);\n lab.yz *= mix(1.15, 1.0, u_base.x);\n lab = mix(u_base, lab, u_intensity * mix(floorMix, 1.0, air));\n\n vec3 col = linearToSrgb(oklabToLinear(lab));\n\n // Film grain on a CSS-pixel grid: two hashes averaged for a softer,\n // more photographic distribution than flat white noise.\n vec2 cell = floor(gl_FragCoord.xy / u_pixel);\n float g = (hash(cell + u_grainSeed) + hash(cell.yx - u_grainSeed * 1.7)) * 0.5;\n col += (g - 0.5) * u_grain * 0.2;\n // Always a hair of dither, so the smooth ramps never band.\n col += (hash(cell * 1.31 + u_grainSeed) - 0.5) * 0.004;\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Hex → OKLab, so the shader can blend without going muddy. */\nfunction hexToOklab(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n const srgb = Number.isNaN(n)\n ? [0.5, 0.5, 0.5]\n : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n const [r, g, b] = srgb.map((c) =>\n c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n ) as [number, number, number]\n const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)\n const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)\n const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)\n return [\n 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n ]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderGradientTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderGradient: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * A grainy liquid gradient for heroes and empty states. The palette flows\n * through slow domain-warped noise, blends in OKLab so neighbours never go\n * muddy, and carries a film grain. Theme-aware; pauses off-screen and in\n * hidden tabs; holds a still frame under `prefers-reduced-motion`.\n * Recovers from a lost WebGL context.\n */\nexport function createShaderGradient(\n canvas: HTMLCanvasElement,\n initial: ShaderGradientOptions = {}\n): ShaderGradientInstance | null {\n let options: ShaderGradientOptions = {\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const target = { x: 0.5, y: 0.5 }\n // Pointer pull eases in and out, so leaving the hero never snaps.\n let pull = 0\n let pullTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n type Loc = WebGLUniformLocation | null\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: Loc, uTime: Loc, uBlur: Loc, uIntensity: Loc, uGrain: Loc\n let uGrainSeed: Loc, uPixel: Loc, uPointer: Loc, uMouse: Loc, uBase: Loc\n let uStops: Loc[] = []\n\n // Builds every GL resource. Runs at startup and again when the browser\n // restores a lost context, since the old objects die with it.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderGradient: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n gl.viewport(0, 0, canvas.width, canvas.height)\n\n const linked = program\n const u = (name: string) => gl.getUniformLocation(linked, name)\n uResolution = u(\"u_resolution\")\n uTime = u(\"u_time\")\n uBlur = u(\"u_blur\")\n uIntensity = u(\"u_intensity\")\n uGrain = u(\"u_grain\")\n uGrainSeed = u(\"u_grainSeed\")\n uPixel = u(\"u_pixel\")\n uPointer = u(\"u_pointer\")\n uMouse = u(\"u_mouse\")\n uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\"), u(\"u_c3\")]\n uBase = u(\"u_base\")\n return true\n }\n if (!initGl()) return null\n\n // The gradient is soft enough that one sample per CSS pixel is plenty;\n // it also keeps the grain the same size on every screen.\n let pixel = 1\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n pixel = Math.min(window.devicePixelRatio || 1, 1)\n canvas.width = Math.max(1, Math.floor(w * pixel))\n canvas.height = Math.max(1, Math.floor(h * pixel))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n\n let colorsKey = \"\"\n const syncColors = () => {\n const source = options.colors?.length\n ? options.colors\n : dark\n ? DARK_COLORS\n : LIGHT_COLORS\n const key = `${source.join()}|${dark}`\n if (key === colorsKey) return\n colorsKey = key\n uStops.forEach((loc, i) => {\n gl.uniform3fv(loc, hexToOklab(source[i % source.length]!))\n })\n gl.uniform3fv(uBase, hexToOklab(dark ? DARK_BASE : LIGHT_BASE))\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let clock = 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n if (live) clock += dt\n\n mouse.x += (target.x - mouse.x) * 0.05\n mouse.y += (target.y - mouse.y) * 0.05\n pull += (pullTarget - pull) * 0.04\n\n syncColors()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, clock * speed * 4)\n gl.uniform1f(uBlur, numberOr(options.blur, DEFAULT_BLUR, 0, 1))\n gl.uniform1f(\n uIntensity,\n numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1)\n )\n gl.uniform1f(uGrain, numberOr(options.grain, DEFAULT_GRAIN, 0, 1))\n // Grain re-rolls at 24 fps, like film, and holds still when paused.\n gl.uniform1f(uGrainSeed, (Math.floor(clock * 24) % 997) * 13.7)\n gl.uniform1f(uPixel, pixel)\n gl.uniform1f(uPointer, options.interactive === false ? 0 : pull)\n gl.uniform2f(uMouse, mouse.x, mouse.y)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n if (live) raf = requestAnimationFrame(tick)\n }\n\n // A GPU reset, a driver update or too many live contexts can drop ours.\n // preventDefault asks the browser to restore it; until then gl is off limits.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n vs = fs = program = buf = null\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n // Uniforms reset with the context, so the colours go up again.\n colorsKey = \"\"\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pullTarget = inside ? 1 : 0\n if (inside) {\n target.x = x\n target.y = y\n }\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderGradientProps = Omit<\n ShaderGradientOptions,\n \"onThemeChange\"\n> & {\n className?: string\n}\n\n/**\n * A grainy liquid gradient for heroes and empty states: the palette flows\n * through slow warped noise under a film grain. Theme-aware.\n */\nexport function ShaderGradient({\n className,\n colors,\n speed = DEFAULT_SPEED,\n blur = DEFAULT_BLUR,\n intensity = DEFAULT_INTENSITY,\n grain = DEFAULT_GRAIN,\n interactive = true,\n theme = \"auto\",\n}: ShaderGradientProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderGradient(canvas, {\n colors,\n speed,\n blur,\n intensity,\n grain,\n interactive,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n blur,\n intensity,\n grain,\n interactive,\n theme,\n onThemeChange: setIsDark,\n })\n }, [colors, speed, blur, intensity, grain, interactive, theme])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderGradientTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderGradientOptions = {\n /**\n * Up to 4 colors, any CSS hex. The flow cycles through them in order and\n * back to the first, so neighbours should sit well together.\n */\n colors?: string[]\n /** Flow speed. Default `0.14`. */\n speed?: number\n /** Softness, 0–1: higher is broader, calmer bands. Default `0.7`. */\n blur?: number\n /** Color strength, 0–1; lower lets the paper / ink show through. Default `0.95`. */\n intensity?: number\n /** Film grain, 0–1. `0` turns it off. Default `0.35`. */\n grain?: number\n /** The flow swirls gently around the pointer. Default `true`. */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so light and dark swap with the site theme.\n */\n theme?: ShaderGradientTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderGradientInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Peach, butter, sky and lilac, flowing over warm paper. */\nexport const LIGHT_COLORS = [\"#F7A48B\", \"#F9D78E\", \"#9FCBF0\", \"#BBA9EE\"]\n/**\n * Blue, violet, magenta and coral over near-black ink: a narrow hue arc,\n * so every blend on the cycle stays rich instead of passing through grey.\n */\nexport const DARK_COLORS = [\"#3D52F2\", \"#9150F2\", \"#E0479F\", \"#FF7B60\"]\nconst LIGHT_BASE = \"#FBF8F4\"\nconst DARK_BASE = \"#07080B\"\n\nexport const DEFAULT_SPEED = 0.14\nexport const DEFAULT_BLUR = 0.7\nexport const DEFAULT_INTENSITY = 0.95\nexport const DEFAULT_GRAIN = 0.35\n\n/** Film grain for the CSS fallback: the same idea, as an SVG turbulence tile. */\nconst GRAIN_TILE = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.16 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")`\n\nfunction fallback(colors: string[], base: string) {\n const [a, b, c, d] = colors\n return {\n backgroundColor: base,\n backgroundImage: [\n GRAIN_TILE,\n `radial-gradient(70% 60% at 18% 22%, ${a} 0%, transparent 70%)`,\n `radial-gradient(60% 60% at 82% 30%, ${b} 0%, transparent 70%)`,\n `radial-gradient(70% 60% at 70% 88%, ${c} 0%, transparent 70%)`,\n `radial-gradient(55% 55% at 12% 86%, ${d} 0%, transparent 70%)`,\n ].join(\", \"),\n } as const\n}\n\n/** Shown before the first frame and wherever WebGL isn't available. */\nexport const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE)\nexport const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE)\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_blur;\nuniform float u_intensity;\nuniform float u_grain;\nuniform float u_grainSeed;\nuniform float u_pixel;\nuniform float u_pointer;\nuniform vec2 u_mouse;\n// Palette stops and the paper / ink underneath, in OKLab.\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_base;\n\n// Dave Hoskins' hash: no sin(), so it stays stable on mobile GPUs.\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 3; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Weight of the stop at 'at' on a cyclic 0–1 track: a smoothed hat, so any\n// two neighbouring weights always sum to one.\nfloat stop(float v, float at) {\n float d = abs(v - at);\n d = min(d, 1.0 - d);\n return smoothstep(0.0, 1.0, 1.0 - clamp(d * 4.0, 0.0, 1.0));\n}\n\nvec3 palette(float v) {\n return u_c0 * stop(v, 0.0) + u_c1 * stop(v, 0.25) +\n u_c2 * stop(v, 0.5) + u_c3 * stop(v, 0.75);\n}\n\nvec3 oklabToLinear(vec3 c) {\n float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z;\n float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z;\n float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.z;\n l = l * l * l;\n m = m * m * m;\n s = s * s * s;\n return vec3(\n 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s\n );\n}\n\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n vec3 lo = c * 12.92;\n vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(vec3(0.0031308), c));\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / u_resolution;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float scale = mix(2.0, 0.8, u_blur);\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float t = u_time;\n\n // A soft swirl around the pointer.\n vec2 d = p - vec2(u_mouse.x * aspect, u_mouse.y) * scale;\n p += vec2(-d.y, d.x) * exp(-dot(d, d) * 1.4) * 0.4 * u_pointer;\n\n // Domain warping: noise fed back through itself twice gives slow,\n // liquid bands that never read as a loop.\n vec2 q = vec2(\n fbm(p + vec2(0.0, 0.12 * t)),\n fbm(p + vec2(5.2, 1.3) - 0.1 * t)\n );\n vec2 r = vec2(\n fbm(p + 2.2 * q + vec2(1.7, 9.2) + 0.07 * t),\n fbm(p + 2.2 * q + vec2(8.3, 2.8) - 0.06 * t)\n );\n float f = fbm(p + 2.0 * r);\n\n // Where on the palette this pixel sits. The track is cyclic, so wrapping\n // past the last color flows back into the first without a seam.\n float v = fract(f * 2.4 + 0.35 * r.x + 0.02 * t);\n vec3 lab = palette(v);\n\n // Some of the paper / ink breathes through, so it isn't wall-to-wall color.\n // Ink dulls far faster than paper does, so on a dark base only a little\n // shows, and the color gets a chroma lift to glow against it.\n float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t));\n float floorMix = mix(0.82, 0.45, u_base.x);\n lab.yz *= mix(1.15, 1.0, u_base.x);\n lab = mix(u_base, lab, u_intensity * mix(floorMix, 1.0, air));\n\n vec3 col = linearToSrgb(oklabToLinear(lab));\n\n // Film grain on a CSS-pixel grid: two hashes averaged for a softer,\n // more photographic distribution than flat white noise.\n vec2 cell = floor(gl_FragCoord.xy / u_pixel);\n float g = (hash(cell + u_grainSeed) + hash(cell.yx - u_grainSeed * 1.7)) * 0.5;\n col += (g - 0.5) * u_grain * 0.2;\n // Always a hair of dither, so the smooth ramps never band.\n col += (hash(cell * 1.31 + u_grainSeed) - 0.5) * 0.004;\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Hex → OKLab, so the shader can blend without going muddy. */\nfunction hexToOklab(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n const srgb = Number.isNaN(n)\n ? [0.5, 0.5, 0.5]\n : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n const [r, g, b] = srgb.map((c) =>\n c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n ) as [number, number, number]\n const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)\n const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)\n const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)\n return [\n 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n ]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderGradientTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderGradient: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * A grainy liquid gradient for heroes and empty states. The palette flows\n * through slow domain-warped noise, blends in OKLab so neighbours never go\n * muddy, and carries a film grain. Theme-aware; pauses off-screen and in\n * hidden tabs; holds a still frame under `prefers-reduced-motion`.\n * Recovers from a lost WebGL context.\n */\nexport function createShaderGradient(\n canvas: HTMLCanvasElement,\n initial: ShaderGradientOptions = {}\n): ShaderGradientInstance | null {\n let options: ShaderGradientOptions = {\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const target = { x: 0.5, y: 0.5 }\n // Pointer pull eases in and out, so leaving the hero never snaps.\n let pull = 0\n let pullTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n type Loc = WebGLUniformLocation | null\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: Loc, uTime: Loc, uBlur: Loc, uIntensity: Loc, uGrain: Loc\n let uGrainSeed: Loc, uPixel: Loc, uPointer: Loc, uMouse: Loc, uBase: Loc\n let uStops: Loc[] = []\n\n // Builds every GL resource. Runs at startup and again when the browser\n // restores a lost context, since the old objects die with it.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderGradient: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n gl.viewport(0, 0, canvas.width, canvas.height)\n\n const linked = program\n const u = (name: string) => gl.getUniformLocation(linked, name)\n uResolution = u(\"u_resolution\")\n uTime = u(\"u_time\")\n uBlur = u(\"u_blur\")\n uIntensity = u(\"u_intensity\")\n uGrain = u(\"u_grain\")\n uGrainSeed = u(\"u_grainSeed\")\n uPixel = u(\"u_pixel\")\n uPointer = u(\"u_pointer\")\n uMouse = u(\"u_mouse\")\n uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\"), u(\"u_c3\")]\n uBase = u(\"u_base\")\n return true\n }\n if (!initGl()) return null\n\n // The gradient is soft enough that one sample per CSS pixel is plenty;\n // it also keeps the grain the same size on every screen.\n let pixel = 1\n /** Sizes the canvas to its parent; true when the backing store changed. */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n pixel = Math.min(window.devicePixelRatio || 1, 1)\n const width = Math.max(1, Math.floor(w * pixel))\n const height = Math.max(1, Math.floor(h * pixel))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n wake()\n // Assigning a size clears the canvas, even the same size.\n if (width === canvas.width && height === canvas.height) return false\n canvas.width = width\n canvas.height = height\n gl.viewport(0, 0, canvas.width, canvas.height)\n return true\n }\n\n let colorsKey = \"\"\n const syncColors = () => {\n const source = options.colors?.length\n ? options.colors\n : dark\n ? DARK_COLORS\n : LIGHT_COLORS\n const key = `${source.join()}|${dark}`\n if (key === colorsKey) return\n colorsKey = key\n uStops.forEach((loc, i) => {\n gl.uniform3fv(loc, hexToOklab(source[i % source.length]!))\n })\n gl.uniform3fv(uBase, hexToOklab(dark ? DARK_BASE : LIGHT_BASE))\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let clock = 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n if (live) clock += dt\n\n mouse.x += (target.x - mouse.x) * 0.05\n mouse.y += (target.y - mouse.y) * 0.05\n pull += (pullTarget - pull) * 0.04\n\n syncColors()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, clock * speed * 4)\n gl.uniform1f(uBlur, numberOr(options.blur, DEFAULT_BLUR, 0, 1))\n gl.uniform1f(\n uIntensity,\n numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1)\n )\n gl.uniform1f(uGrain, numberOr(options.grain, DEFAULT_GRAIN, 0, 1))\n // Grain re-rolls at 24 fps, like film, and holds still when paused.\n gl.uniform1f(uGrainSeed, (Math.floor(clock * 24) % 997) * 13.7)\n gl.uniform1f(uPixel, pixel)\n gl.uniform1f(uPointer, options.interactive === false ? 0 : pull)\n gl.uniform2f(uMouse, mouse.x, mouse.y)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n if (live) raf = requestAnimationFrame(tick)\n }\n\n /**\n * Draws now: resizing cleared the canvas, and waiting for the next frame\n * would show it blank for one.\n */\n const redraw = () => {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n // A GPU reset, a driver update or too many live contexts can drop ours.\n // preventDefault asks the browser to restore it; until then gl is off limits.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n vs = fs = program = buf = null\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n // Uniforms reset with the context, so the colours go up again.\n colorsKey = \"\"\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pullTarget = inside ? 1 : 0\n if (inside) {\n target.x = x\n target.y = y\n }\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderGradientProps = Omit<\n ShaderGradientOptions,\n \"onThemeChange\"\n> & {\n className?: string\n}\n\n/**\n * A grainy liquid gradient for heroes and empty states: the palette flows\n * through slow warped noise under a film grain. Theme-aware.\n */\nexport function ShaderGradient({\n className,\n colors,\n speed = DEFAULT_SPEED,\n blur = DEFAULT_BLUR,\n intensity = DEFAULT_INTENSITY,\n grain = DEFAULT_GRAIN,\n interactive = true,\n theme = \"auto\",\n}: ShaderGradientProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderGradient(canvas, {\n colors,\n speed,\n blur,\n intensity,\n grain,\n interactive,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n blur,\n intensity,\n grain,\n interactive,\n theme,\n onThemeChange: setIsDark,\n })\n }, [colors, speed, blur, intensity, grain, interactive, theme])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/shader-gradient.tsx" } diff --git a/public/r/shader-metal-svelte.json b/public/r/shader-metal-svelte.json index 5ed017c..38d6fd9 100644 --- a/public/r/shader-metal-svelte.json +++ b/public/r/shader-metal-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-metal/shader-metal.svelte", - "content": "\n\n\n\n\n \n \n\n", + "content": "\n\n\n\n\n \n \n\n", "type": "registry:file", "target": "src/lib/components/ui/shader-metal.svelte" } diff --git a/public/r/shader-metal.json b/public/r/shader-metal.json index fe49495..d5d2b8c 100644 --- a/public/r/shader-metal.json +++ b/public/r/shader-metal.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-metal/shader-metal.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderMetalTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderMetalMetal = \"chrome\" | \"gold\" | \"copper\" | \"graphite\"\n\nexport type ShaderMetalOptions = {\n /** Which metal the ribbons are cast in. Default `chrome`. */\n metal?: ShaderMetalMetal\n /** Custom metal tint, any CSS hex. Wins over `metal`. */\n color?: string\n /** How many ribbons, 1–5. Default `3`. */\n ribbons?: number\n /** Flow speed. Default `0.3`. */\n speed?: number\n /** Thin-film sheen on the turns, 0–1. `0` is bare metal. Default `0.2`. */\n iridescence?: number\n /** The key light follows the pointer and the ribbons lean toward it. Default `true`. */\n interactive?: boolean\n /**\n * Studio lighting. Default `auto` follows shadcn / next-themes\n * (`html.dark` class): a bright softbox studio in light mode, a black\n * stage with strip lights in dark.\n */\n theme?: ShaderMetalTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderMetalInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Base reflectance per metal, as sRGB hex. */\nexport const METALS: Record = {\n chrome: \"#F2F3F5\",\n gold: \"#FFD38A\",\n copper: \"#F7AE92\",\n graphite: \"#8E9096\",\n}\n\nexport const DEFAULT_METAL: ShaderMetalMetal = \"chrome\"\nexport const DEFAULT_RIBBONS = 3\nexport const DEFAULT_SPEED = 0.3\nexport const DEFAULT_IRIDESCENCE = 0.2\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_count;\nuniform float u_iridescence;\nuniform float u_pull;\nuniform float u_dark;\nuniform vec2 u_mouse;\nuniform vec2 u_light;\nuniform vec3 u_f0;\n\nconst float TAU = 6.28318530718;\nconst float TILT = -0.32;\n\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n// Smooth 1D value noise, for the brushed grain that runs along each ribbon.\nfloat lines(float x, float seed) {\n float i = floor(x);\n float f = fract(x);\n f = f * f * (3.0 - 2.0 * f);\n return mix(hash(vec2(i, seed)), hash(vec2(i + 1.0, seed)), f);\n}\n\nvec2 rotate(vec2 p, float a) {\n float c = cos(a);\n float s = sin(a);\n return vec2(c * p.x - s * p.y, s * p.x + c * p.y);\n}\n\n// The spine of ribbon k: three swells at different speeds, so it never\n// settles into a loop. Near the pointer it leans toward it.\nfloat spine(float x, float k, float lane, float t, vec2 m) {\n float y = lane;\n y += 0.13 * sin(1.15 * x + 0.55 * t + 2.1 * k);\n y += 0.06 * sin(2.6 * x - 0.8 * t + 1.3 * k + 0.7);\n y += 0.025 * sin(5.3 * x + 1.3 * t + 4.0 * k);\n float near = exp(-(x - m.x) * (x - m.x) * 5.0);\n y += (m.y - y) * 0.22 * u_pull * near;\n return y;\n}\n\n// A studio to reflect: sky and floor meet at a crisp horizon, a big\n// softbox overhead, two strip lights and the key light. Linear light.\nvec3 studio(vec3 r, float t) {\n float a = 0.28 * sin(t * 0.11);\n r.xz = rotate(r.xz, a);\n float y = r.y;\n\n vec3 top = mix(vec3(0.82, 0.83, 0.86), vec3(0.025, 0.025, 0.028), u_dark);\n vec3 horizon = mix(vec3(1.0), vec3(0.16, 0.16, 0.17), u_dark);\n vec3 ground = mix(vec3(0.05, 0.05, 0.055), vec3(0.004), u_dark);\n vec3 sky = mix(horizon, top, smoothstep(0.0, 0.85, y));\n vec3 floor_ = mix(ground * 2.4, ground, smoothstep(0.0, -0.5, y));\n vec3 c = mix(floor_, sky, smoothstep(-0.025, 0.025, y));\n\n // Overhead softbox, feathered.\n float box = smoothstep(0.6, 0.45, abs(r.x)) * smoothstep(0.42, 0.55, y) *\n smoothstep(1.0, 0.85, y);\n c += box * mix(1.6, 2.2, u_dark);\n\n // Tall strip lights left and right: warm and cool, faintly.\n float stripL = smoothstep(0.1, 0.03, abs(r.x + 0.78)) * smoothstep(-0.35, -0.1, y) * smoothstep(0.8, 0.5, y);\n float stripR = smoothstep(0.08, 0.02, abs(r.x - 0.72)) * smoothstep(-0.3, -0.05, y) * smoothstep(0.75, 0.45, y);\n c += stripL * vec3(1.25, 1.1, 0.95) * mix(1.0, 1.5, u_dark);\n c += stripR * vec3(0.9, 1.05, 1.3) * mix(0.8, 1.3, u_dark);\n\n // A low bounce card, so the undersides aren't dead.\n float card = smoothstep(0.7, 0.2, abs(r.x)) * smoothstep(-0.95, -0.7, y) * smoothstep(-0.4, -0.65, y);\n c += card * mix(0.35, 0.18, u_dark);\n return c;\n}\n\nvec3 tonemap(vec3 x) {\n x = max(x, 0.0);\n return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);\n}\n\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n vec3 lo = c * 12.92;\n vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(vec3(0.0031308), c));\n}\n\nvoid main() {\n vec2 frag = gl_FragCoord.xy;\n float px = 1.0 / u_resolution.y;\n vec2 p = rotate((frag - 0.5 * u_resolution) * px, TILT);\n vec2 m = rotate((u_mouse - 0.5) * u_resolution * px, TILT);\n float t = u_time;\n\n vec3 L = normalize(vec3(u_light, 0.85));\n float count = u_count;\n float spread = mix(0.0, 0.3, clamp((count - 1.0) / 4.0, 0.0, 1.0)) + 0.08 * step(1.5, count);\n float baseWidth = mix(0.13, 0.075, clamp((count - 1.0) / 4.0, 0.0, 1.0));\n\n // Premultiplied, composited back to front.\n vec3 col = vec3(0.0);\n float alpha = 0.0;\n\n for (int i = 0; i < 5; i++) {\n float k = float(i);\n if (k >= count) break;\n float f = count > 1.0 ? k / (count - 1.0) : 0.5;\n float lane = (f - 0.5) * spread;\n // Farther ribbons drift a touch slower: a hint of depth.\n float tk = t * mix(0.82, 1.0, f);\n\n float x = p.x;\n float e = 0.004;\n float c = spine(x, k, lane, tk, m);\n float slope = (spine(x + e, k, lane, tk, m) - spine(x - e, k, lane, tk, m)) / (2.0 * e);\n float stretch = inversesqrt(1.0 + slope * slope);\n float d = (p.y - c) * stretch;\n\n // Twist along the length; width breathes.\n float theta = 1.5 * x + 0.45 * tk + 1.7 * k + 0.55 * sin(0.7 * x - 0.3 * tk + k);\n float ct = cos(theta);\n float st = sin(theta);\n float hw = baseWidth * (0.82 + 0.22 * sin(0.9 * x + 0.35 * tk + 2.3 * k));\n // Seen edge-on a ribbon still has a little thickness.\n float proj = hw * max(abs(ct), 0.035);\n\n // Soft contact shadow on whatever is behind, before the ribbon lands.\n float sd = d + 0.045;\n float shadowW = proj + 0.05;\n float shadow = (1.0 - smoothstep(0.35, 1.0, abs(sd) / shadowW)) * mix(0.2, 0.32, u_dark);\n col *= 1.0 - shadow;\n alpha = alpha + shadow * (1.0 - alpha);\n\n float v = d / (ct >= 0.0 ? proj : -proj);\n float aa = 1.2 * px / proj;\n float cover = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, abs(v));\n if (cover <= 0.0) continue;\n\n // Ribbon frame: T along, N2 across in the screen plane, Z toward us.\n vec3 T = normalize(vec3(1.0, slope, 0.0));\n vec3 N2 = normalize(vec3(-slope, 1.0, 0.0));\n vec3 B = ct * N2 + st * vec3(0.0, 0.0, 1.0);\n vec3 n = -st * N2 + ct * vec3(0.0, 0.0, 1.0);\n\n // A shallow arc across the width, rolling over at the edges, plus\n // slow ripples running down the length and a brushed grain.\n float roll = v * 0.45 + v * v * v * 0.5;\n float ripple = sin(x * 13.0 - tk * 2.2 + k * 3.1) * 0.06 + sin(x * 31.0 + tk * 1.3 + k) * 0.015;\n float grain = lines(v * 34.0 + k * 17.0, k) - 0.5;\n n = normalize(n + B * (roll + grain * 0.015) + T * ripple);\n // Edge-on, what shows is the strip's polished edge. It faces us, tipped\n // up a little so it catches the sky rather than sitting on the horizon.\n float edge = smoothstep(0.14, 0.03, abs(ct));\n vec3 rim = B * (st >= 0.0 ? 1.0 : -1.0) + vec3(0.0, 0.2, 0.0);\n n = normalize(mix(n, normalize(rim), edge));\n float back = step(n.z, 0.0);\n n *= 1.0 - 2.0 * back;\n\n vec3 r = reflect(vec3(0.0, 0.0, -1.0), n);\n vec3 env = studio(r, t);\n\n float facing = clamp(n.z, 0.0, 1.0);\n vec3 F = u_f0 + (1.0 - u_f0) * pow(1.0 - facing, 5.0);\n\n // Thin film: thickness drifts along the ribbon, and the colour shifts\n // with the viewing angle, strongest on the turns.\n float film = 0.5 + 0.5 * sin(x * 1.6 + v * 0.9 + tk * 0.35 + k * 1.9);\n vec3 rainbow = 0.5 + 0.5 * cos(TAU * (film * 1.6 + facing * 1.1 + vec3(0.0, 0.33, 0.67)));\n // Keep the film's brightness level, so it tints the metal, never dims it.\n rainbow = 0.2 + 0.8 * rainbow;\n rainbow /= max(dot(rainbow, vec3(0.2126, 0.7152, 0.0722)), 0.2);\n float sheen = u_iridescence * (0.45 + 0.55 * (1.0 - facing));\n F *= mix(vec3(1.0), rainbow, sheen);\n\n float rl = max(dot(r, L), 0.0);\n float spec = pow(rl, 90.0) * 5.0 + pow(rl, 14.0) * 0.55;\n\n vec3 lit = env * F + spec * F;\n lit *= mix(1.0, 0.72, back) * (0.94 + grain * 0.12);\n lit *= mix(0.86, 1.0, f);\n vec3 rgb = linearToSrgb(tonemap(lit * 1.05));\n\n col = col * (1.0 - cover) + rgb * cover;\n alpha = alpha * (1.0 - cover) + cover;\n }\n\n // A hair of dither, so the long gradients never band. Faint shadows\n // need it on alpha too, or they step into contour lines.\n float dither = (hash(frag + fract(t) * 91.0) - 0.5) / 255.0;\n col += dither * alpha;\n alpha = clamp(alpha + dither * step(0.0005, alpha) * 1.5, 0.0, 1.0);\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), alpha);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Hex → linear RGB, for the metal's base reflectance. */\nfunction hexToLinear(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n const srgb = Number.isNaN(n)\n ? [0.8, 0.8, 0.8]\n : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n return srgb.map((c) =>\n c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n ) as [number, number, number]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderMetalTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderMetal: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/** Most pixels we'll shade per frame; past this the canvas renders softer. */\nconst MAX_PIXELS = 2_400_000\n/** Where the key light rests when the pointer is away: up and to the left. */\nconst REST_LIGHT = { x: -0.45, y: 0.55 }\n\n/**\n * Liquid metal ribbons that twist through a studio light rig. Each ribbon\n * is a curved, brushed strip reflecting a softbox, strip lights and a\n * crisp horizon, with a key light the pointer can steer. The canvas is\n * transparent, so the ribbons and their soft shadows sit on any page.\n * Theme-aware; pauses off-screen and in hidden tabs; holds a still frame\n * under `prefers-reduced-motion`. Recovers from a lost WebGL context.\n */\nexport function createShaderMetal(\n canvas: HTMLCanvasElement,\n initial: ShaderMetalOptions = {}\n): ShaderMetalInstance | null {\n let options: ShaderMetalOptions = {\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const target = { x: 0.5, y: 0.5 }\n const light = { ...REST_LIGHT }\n const lightTarget = { ...REST_LIGHT }\n let pull = 0\n let pullTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n let darkMix = dark ? 1 : 0\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: WebGLUniformLocation | null = null\n let uTime: WebGLUniformLocation | null = null\n let uCount: WebGLUniformLocation | null = null\n let uIridescence: WebGLUniformLocation | null = null\n let uPull: WebGLUniformLocation | null = null\n let uDark: WebGLUniformLocation | null = null\n let uMouse: WebGLUniformLocation | null = null\n let uLight: WebGLUniformLocation | null = null\n let uF0: WebGLUniformLocation | null = null\n\n // Builds every GL resource. Runs at startup and again after a lost\n // context is restored, since a restore hands back a blank context.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderMetal: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const prog = program\n const u = (name: string) => gl.getUniformLocation(prog, name)\n uResolution = u(\"u_resolution\")\n uTime = u(\"u_time\")\n uCount = u(\"u_count\")\n uIridescence = u(\"u_iridescence\")\n uPull = u(\"u_pull\")\n uDark = u(\"u_dark\")\n uMouse = u(\"u_mouse\")\n uLight = u(\"u_light\")\n uF0 = u(\"u_f0\")\n return true\n }\n if (!initGl()) return null\n\n // Ribbon edges are hard, so this one renders at device resolution (up to 2x).\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n const ratio = Math.min(\n window.devicePixelRatio || 1,\n 2,\n Math.sqrt(MAX_PIXELS / (w * h))\n )\n canvas.width = Math.max(1, Math.floor(w * ratio))\n canvas.height = Math.max(1, Math.floor(h * ratio))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n\n let f0Key = \"\"\n const syncMetal = () => {\n const hex =\n options.color || METALS[options.metal ?? DEFAULT_METAL] || METALS.chrome\n if (hex === f0Key) return\n f0Key = hex\n gl.uniform3fv(uF0, hexToLinear(hex))\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let clock = 0\n\n const visible = () => onScreen && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // Still easing toward the pointer or the theme, even with motion paused.\n const settling = () =>\n Math.abs(target.x - mouse.x) > 1e-3 ||\n Math.abs(target.y - mouse.y) > 1e-3 ||\n Math.abs(lightTarget.x - light.x) > 1e-3 ||\n Math.abs(lightTarget.y - light.y) > 1e-3 ||\n Math.abs(pullTarget - pull) > 1e-3 ||\n Math.abs((dark ? 1 : 0) - darkMix) > 1e-3\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (!visible()) return\n const flowing = !mqReduce.matches\n if (flowing) clock += dt\n\n // Frame-rate independent easing.\n const ease = (rate: number) => 1 - Math.exp(-dt * rate)\n mouse.x += (target.x - mouse.x) * ease(3)\n mouse.y += (target.y - mouse.y) * ease(3)\n light.x += (lightTarget.x - light.x) * ease(5)\n light.y += (lightTarget.y - light.y) * ease(5)\n pull += (pullTarget - pull) * ease(2.5)\n darkMix += ((dark ? 1 : 0) - darkMix) * ease(6)\n\n syncMetal()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, clock * speed * 3 + 11)\n gl.uniform1f(\n uCount,\n Math.round(numberOr(options.ribbons, DEFAULT_RIBBONS, 1, 5))\n )\n gl.uniform1f(\n uIridescence,\n numberOr(options.iridescence, DEFAULT_IRIDESCENCE, 0, 1)\n )\n gl.uniform1f(uPull, options.interactive === false ? 0 : pull)\n gl.uniform1f(uDark, darkMix)\n gl.uniform2f(uMouse, mouse.x, mouse.y)\n gl.uniform2f(uLight, light.x, light.y)\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n if (flowing || settling()) raf = requestAnimationFrame(tick)\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pullTarget = inside ? 1 : 0\n if (inside) {\n target.x = x\n target.y = y\n lightTarget.x = (x * 2 - 1) * 0.9\n lightTarget.y = (y * 2 - 1) * 0.9\n } else {\n lightTarget.x = REST_LIGHT.x\n lightTarget.y = REST_LIGHT.y\n }\n wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n // Without preventDefault the browser never restores a lost context.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n f0Key = \"\"\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (options.interactive === false) {\n pullTarget = 0\n lightTarget.x = REST_LIGHT.x\n lightTarget.y = REST_LIGHT.y\n }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderMetalProps = Omit & {\n className?: string\n}\n\n/**\n * Liquid metal ribbons twisting through a studio light rig: brushed,\n * curved strips with soft shadows on a transparent canvas. Theme-aware.\n */\nexport function ShaderMetal({\n className,\n metal = DEFAULT_METAL,\n color,\n ribbons = DEFAULT_RIBBONS,\n speed = DEFAULT_SPEED,\n iridescence = DEFAULT_IRIDESCENCE,\n interactive = true,\n theme = \"auto\",\n}: ShaderMetalProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderMetal(canvas, {\n metal,\n color,\n ribbons,\n speed,\n iridescence,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n metal,\n color,\n ribbons,\n speed,\n iridescence,\n interactive,\n theme,\n })\n }, [metal, color, ribbons, speed, iridescence, interactive, theme])\n\n return (\n \n {/* Firefox can hand a WebGL canvas straight to the system compositor,\n which ignores rounded clips. The mask on the root keeps it in the\n page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderMetalTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderMetalMetal = \"chrome\" | \"gold\" | \"copper\" | \"graphite\"\n\nexport type ShaderMetalOptions = {\n /** Which metal the ribbons are cast in. Default `chrome`. */\n metal?: ShaderMetalMetal\n /** Custom metal tint, any CSS hex. Wins over `metal`. */\n color?: string\n /** How many ribbons, 1–5. Default `3`. */\n ribbons?: number\n /** Flow speed. Default `0.3`. */\n speed?: number\n /** Thin-film sheen on the turns, 0–1. `0` is bare metal. Default `0.2`. */\n iridescence?: number\n /** The key light follows the pointer and the ribbons lean toward it. Default `true`. */\n interactive?: boolean\n /**\n * Studio lighting. Default `auto` follows shadcn / next-themes\n * (`html.dark` class): a bright softbox studio in light mode, a black\n * stage with strip lights in dark.\n */\n theme?: ShaderMetalTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderMetalInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Base reflectance per metal, as sRGB hex. */\nexport const METALS: Record = {\n chrome: \"#F2F3F5\",\n gold: \"#FFD38A\",\n copper: \"#F7AE92\",\n graphite: \"#8E9096\",\n}\n\nexport const DEFAULT_METAL: ShaderMetalMetal = \"chrome\"\nexport const DEFAULT_RIBBONS = 3\nexport const DEFAULT_SPEED = 0.3\nexport const DEFAULT_IRIDESCENCE = 0.2\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_count;\nuniform float u_iridescence;\nuniform float u_pull;\nuniform float u_dark;\nuniform vec2 u_mouse;\nuniform vec2 u_light;\nuniform vec3 u_f0;\n\nconst float TAU = 6.28318530718;\nconst float TILT = -0.32;\n\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n// Smooth 1D value noise, for the brushed grain that runs along each ribbon.\nfloat lines(float x, float seed) {\n float i = floor(x);\n float f = fract(x);\n f = f * f * (3.0 - 2.0 * f);\n return mix(hash(vec2(i, seed)), hash(vec2(i + 1.0, seed)), f);\n}\n\nvec2 rotate(vec2 p, float a) {\n float c = cos(a);\n float s = sin(a);\n return vec2(c * p.x - s * p.y, s * p.x + c * p.y);\n}\n\n// The spine of ribbon k: three swells at different speeds, so it never\n// settles into a loop. Near the pointer it leans toward it.\nfloat spine(float x, float k, float lane, float t, vec2 m) {\n float y = lane;\n y += 0.13 * sin(1.15 * x + 0.55 * t + 2.1 * k);\n y += 0.06 * sin(2.6 * x - 0.8 * t + 1.3 * k + 0.7);\n y += 0.025 * sin(5.3 * x + 1.3 * t + 4.0 * k);\n float near = exp(-(x - m.x) * (x - m.x) * 5.0);\n y += (m.y - y) * 0.22 * u_pull * near;\n return y;\n}\n\n// A studio to reflect: sky and floor meet at a crisp horizon, a big\n// softbox overhead, two strip lights and the key light. Linear light.\nvec3 studio(vec3 r, float t) {\n float a = 0.28 * sin(t * 0.11);\n r.xz = rotate(r.xz, a);\n float y = r.y;\n\n vec3 top = mix(vec3(0.82, 0.83, 0.86), vec3(0.025, 0.025, 0.028), u_dark);\n vec3 horizon = mix(vec3(1.0), vec3(0.16, 0.16, 0.17), u_dark);\n vec3 ground = mix(vec3(0.05, 0.05, 0.055), vec3(0.004), u_dark);\n vec3 sky = mix(horizon, top, smoothstep(0.0, 0.85, y));\n vec3 floor_ = mix(ground * 2.4, ground, smoothstep(0.0, -0.5, y));\n vec3 c = mix(floor_, sky, smoothstep(-0.025, 0.025, y));\n\n // Overhead softbox, feathered.\n float box = smoothstep(0.6, 0.45, abs(r.x)) * smoothstep(0.42, 0.55, y) *\n smoothstep(1.0, 0.85, y);\n c += box * mix(1.6, 2.2, u_dark);\n\n // Tall strip lights left and right: warm and cool, faintly.\n float stripL = smoothstep(0.1, 0.03, abs(r.x + 0.78)) * smoothstep(-0.35, -0.1, y) * smoothstep(0.8, 0.5, y);\n float stripR = smoothstep(0.08, 0.02, abs(r.x - 0.72)) * smoothstep(-0.3, -0.05, y) * smoothstep(0.75, 0.45, y);\n c += stripL * vec3(1.25, 1.1, 0.95) * mix(1.0, 1.5, u_dark);\n c += stripR * vec3(0.9, 1.05, 1.3) * mix(0.8, 1.3, u_dark);\n\n // A low bounce card, so the undersides aren't dead.\n float card = smoothstep(0.7, 0.2, abs(r.x)) * smoothstep(-0.95, -0.7, y) * smoothstep(-0.4, -0.65, y);\n c += card * mix(0.35, 0.18, u_dark);\n return c;\n}\n\nvec3 tonemap(vec3 x) {\n x = max(x, 0.0);\n return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);\n}\n\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n vec3 lo = c * 12.92;\n vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(vec3(0.0031308), c));\n}\n\nvoid main() {\n vec2 frag = gl_FragCoord.xy;\n float px = 1.0 / u_resolution.y;\n vec2 p = rotate((frag - 0.5 * u_resolution) * px, TILT);\n vec2 m = rotate((u_mouse - 0.5) * u_resolution * px, TILT);\n float t = u_time;\n\n vec3 L = normalize(vec3(u_light, 0.85));\n float count = u_count;\n float spread = mix(0.0, 0.3, clamp((count - 1.0) / 4.0, 0.0, 1.0)) + 0.08 * step(1.5, count);\n float baseWidth = mix(0.13, 0.075, clamp((count - 1.0) / 4.0, 0.0, 1.0));\n\n // Premultiplied, composited back to front.\n vec3 col = vec3(0.0);\n float alpha = 0.0;\n\n for (int i = 0; i < 5; i++) {\n float k = float(i);\n if (k >= count) break;\n float f = count > 1.0 ? k / (count - 1.0) : 0.5;\n float lane = (f - 0.5) * spread;\n // Farther ribbons drift a touch slower: a hint of depth.\n float tk = t * mix(0.82, 1.0, f);\n\n float x = p.x;\n float e = 0.004;\n float c = spine(x, k, lane, tk, m);\n float slope = (spine(x + e, k, lane, tk, m) - spine(x - e, k, lane, tk, m)) / (2.0 * e);\n float stretch = inversesqrt(1.0 + slope * slope);\n float d = (p.y - c) * stretch;\n\n // Twist along the length; width breathes.\n float theta = 1.5 * x + 0.45 * tk + 1.7 * k + 0.55 * sin(0.7 * x - 0.3 * tk + k);\n float ct = cos(theta);\n float st = sin(theta);\n float hw = baseWidth * (0.82 + 0.22 * sin(0.9 * x + 0.35 * tk + 2.3 * k));\n // Seen edge-on a ribbon still has a little thickness.\n float proj = hw * max(abs(ct), 0.035);\n\n // Soft contact shadow on whatever is behind, before the ribbon lands.\n float sd = d + 0.045;\n float shadowW = proj + 0.05;\n float shadow = (1.0 - smoothstep(0.35, 1.0, abs(sd) / shadowW)) * mix(0.2, 0.32, u_dark);\n col *= 1.0 - shadow;\n alpha = alpha + shadow * (1.0 - alpha);\n\n float v = d / (ct >= 0.0 ? proj : -proj);\n float aa = 1.2 * px / proj;\n float cover = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, abs(v));\n if (cover <= 0.0) continue;\n\n // Ribbon frame: T along, N2 across in the screen plane, Z toward us.\n vec3 T = normalize(vec3(1.0, slope, 0.0));\n vec3 N2 = normalize(vec3(-slope, 1.0, 0.0));\n vec3 B = ct * N2 + st * vec3(0.0, 0.0, 1.0);\n vec3 n = -st * N2 + ct * vec3(0.0, 0.0, 1.0);\n\n // A shallow arc across the width, rolling over at the edges, plus\n // slow ripples running down the length and a brushed grain.\n float roll = v * 0.45 + v * v * v * 0.5;\n float ripple = sin(x * 13.0 - tk * 2.2 + k * 3.1) * 0.06 + sin(x * 31.0 + tk * 1.3 + k) * 0.015;\n float grain = lines(v * 34.0 + k * 17.0, k) - 0.5;\n n = normalize(n + B * (roll + grain * 0.015) + T * ripple);\n // Edge-on, what shows is the strip's polished edge. It faces us, tipped\n // up a little so it catches the sky rather than sitting on the horizon.\n float edge = smoothstep(0.14, 0.03, abs(ct));\n vec3 rim = B * (st >= 0.0 ? 1.0 : -1.0) + vec3(0.0, 0.2, 0.0);\n n = normalize(mix(n, normalize(rim), edge));\n float back = step(n.z, 0.0);\n n *= 1.0 - 2.0 * back;\n\n vec3 r = reflect(vec3(0.0, 0.0, -1.0), n);\n vec3 env = studio(r, t);\n\n float facing = clamp(n.z, 0.0, 1.0);\n vec3 F = u_f0 + (1.0 - u_f0) * pow(1.0 - facing, 5.0);\n\n // Thin film: thickness drifts along the ribbon, and the colour shifts\n // with the viewing angle, strongest on the turns.\n float film = 0.5 + 0.5 * sin(x * 1.6 + v * 0.9 + tk * 0.35 + k * 1.9);\n vec3 rainbow = 0.5 + 0.5 * cos(TAU * (film * 1.6 + facing * 1.1 + vec3(0.0, 0.33, 0.67)));\n // Keep the film's brightness level, so it tints the metal, never dims it.\n rainbow = 0.2 + 0.8 * rainbow;\n rainbow /= max(dot(rainbow, vec3(0.2126, 0.7152, 0.0722)), 0.2);\n float sheen = u_iridescence * (0.45 + 0.55 * (1.0 - facing));\n F *= mix(vec3(1.0), rainbow, sheen);\n\n float rl = max(dot(r, L), 0.0);\n float spec = pow(rl, 90.0) * 5.0 + pow(rl, 14.0) * 0.55;\n\n vec3 lit = env * F + spec * F;\n lit *= mix(1.0, 0.72, back) * (0.94 + grain * 0.12);\n lit *= mix(0.86, 1.0, f);\n vec3 rgb = linearToSrgb(tonemap(lit * 1.05));\n\n col = col * (1.0 - cover) + rgb * cover;\n alpha = alpha * (1.0 - cover) + cover;\n }\n\n // A hair of dither, so the long gradients never band. Faint shadows\n // need it on alpha too, or they step into contour lines.\n float dither = (hash(frag + fract(t) * 91.0) - 0.5) / 255.0;\n col += dither * alpha;\n alpha = clamp(alpha + dither * step(0.0005, alpha) * 1.5, 0.0, 1.0);\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), alpha);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Hex → linear RGB, for the metal's base reflectance. */\nfunction hexToLinear(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n const srgb = Number.isNaN(n)\n ? [0.8, 0.8, 0.8]\n : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n return srgb.map((c) =>\n c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n ) as [number, number, number]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderMetalTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderMetal: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/** Most pixels we'll shade per frame; past this the canvas renders softer. */\nconst MAX_PIXELS = 2_400_000\n/** Where the key light rests when the pointer is away: up and to the left. */\nconst REST_LIGHT = { x: -0.45, y: 0.55 }\n\n/**\n * Liquid metal ribbons that twist through a studio light rig. Each ribbon\n * is a curved, brushed strip reflecting a softbox, strip lights and a\n * crisp horizon, with a key light the pointer can steer. The canvas is\n * transparent, so the ribbons and their soft shadows sit on any page.\n * Theme-aware; pauses off-screen and in hidden tabs; holds a still frame\n * under `prefers-reduced-motion`. Recovers from a lost WebGL context.\n */\nexport function createShaderMetal(\n canvas: HTMLCanvasElement,\n initial: ShaderMetalOptions = {}\n): ShaderMetalInstance | null {\n let options: ShaderMetalOptions = {\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const target = { x: 0.5, y: 0.5 }\n const light = { ...REST_LIGHT }\n const lightTarget = { ...REST_LIGHT }\n let pull = 0\n let pullTarget = 0\n let dark = resolveDark(options.theme ?? \"auto\")\n let darkMix = dark ? 1 : 0\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let uResolution: WebGLUniformLocation | null = null\n let uTime: WebGLUniformLocation | null = null\n let uCount: WebGLUniformLocation | null = null\n let uIridescence: WebGLUniformLocation | null = null\n let uPull: WebGLUniformLocation | null = null\n let uDark: WebGLUniformLocation | null = null\n let uMouse: WebGLUniformLocation | null = null\n let uLight: WebGLUniformLocation | null = null\n let uF0: WebGLUniformLocation | null = null\n\n // Builds every GL resource. Runs at startup and again after a lost\n // context is restored, since a restore hands back a blank context.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderMetal: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const prog = program\n const u = (name: string) => gl.getUniformLocation(prog, name)\n uResolution = u(\"u_resolution\")\n uTime = u(\"u_time\")\n uCount = u(\"u_count\")\n uIridescence = u(\"u_iridescence\")\n uPull = u(\"u_pull\")\n uDark = u(\"u_dark\")\n uMouse = u(\"u_mouse\")\n uLight = u(\"u_light\")\n uF0 = u(\"u_f0\")\n return true\n }\n if (!initGl()) return null\n\n // Ribbon edges are hard, so this one renders at device resolution (up to 2x).\n // Returns true when the backing store changed.\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n const ratio = Math.min(\n window.devicePixelRatio || 1,\n 2,\n Math.sqrt(MAX_PIXELS / (w * h))\n )\n const width = Math.max(1, Math.floor(w * ratio))\n const height = Math.max(1, Math.floor(h * ratio))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n wake()\n // Assigning a size clears the canvas, even the same size.\n if (width === canvas.width && height === canvas.height) return false\n canvas.width = width\n canvas.height = height\n gl.viewport(0, 0, canvas.width, canvas.height)\n return true\n }\n\n let f0Key = \"\"\n const syncMetal = () => {\n const hex =\n options.color || METALS[options.metal ?? DEFAULT_METAL] || METALS.chrome\n if (hex === f0Key) return\n f0Key = hex\n gl.uniform3fv(uF0, hexToLinear(hex))\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let clock = 0\n\n const visible = () => onScreen && document.visibilityState !== \"hidden\"\n\n function wake() {\n // An observer callback can still arrive after destroy.\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // Still easing toward the pointer or the theme, even with motion paused.\n const settling = () =>\n Math.abs(target.x - mouse.x) > 1e-3 ||\n Math.abs(target.y - mouse.y) > 1e-3 ||\n Math.abs(lightTarget.x - light.x) > 1e-3 ||\n Math.abs(lightTarget.y - light.y) > 1e-3 ||\n Math.abs(pullTarget - pull) > 1e-3 ||\n Math.abs((dark ? 1 : 0) - darkMix) > 1e-3\n\n // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside.\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (!visible()) return\n const flowing = !mqReduce.matches\n if (flowing) clock += dt\n\n // Frame-rate independent easing.\n const ease = (rate: number) => 1 - Math.exp(-dt * rate)\n mouse.x += (target.x - mouse.x) * ease(3)\n mouse.y += (target.y - mouse.y) * ease(3)\n light.x += (lightTarget.x - light.x) * ease(5)\n light.y += (lightTarget.y - light.y) * ease(5)\n pull += (pullTarget - pull) * ease(2.5)\n darkMix += ((dark ? 1 : 0) - darkMix) * ease(6)\n\n syncMetal()\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, clock * speed * 3 + 11)\n gl.uniform1f(\n uCount,\n Math.round(numberOr(options.ribbons, DEFAULT_RIBBONS, 1, 5))\n )\n gl.uniform1f(\n uIridescence,\n numberOr(options.iridescence, DEFAULT_IRIDESCENCE, 0, 1)\n )\n gl.uniform1f(uPull, options.interactive === false ? 0 : pull)\n gl.uniform1f(uDark, darkMix)\n gl.uniform2f(uMouse, mouse.x, mouse.y)\n gl.uniform2f(uLight, light.x, light.y)\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n if (flowing || settling()) raf = requestAnimationFrame(tick)\n }\n\n /**\n * Draws now: resizing cleared the canvas, and waiting for the next frame\n * would show it blank for one.\n */\n const redraw = () => {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (options.interactive === false) return\n const rect = canvas.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pullTarget = inside ? 1 : 0\n if (inside) {\n target.x = x\n target.y = y\n lightTarget.x = (x * 2 - 1) * 0.9\n lightTarget.y = (y * 2 - 1) * 0.9\n } else {\n lightTarget.x = REST_LIGHT.x\n lightTarget.y = REST_LIGHT.y\n }\n wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n // Without preventDefault the browser never restores a lost context.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n f0Key = \"\"\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (options.interactive === false) {\n pullTarget = 0\n lightTarget.x = REST_LIGHT.x\n lightTarget.y = REST_LIGHT.y\n }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderMetalProps = Omit & {\n className?: string\n}\n\n/**\n * Liquid metal ribbons twisting through a studio light rig: brushed,\n * curved strips with soft shadows on a transparent canvas. Theme-aware.\n */\nexport function ShaderMetal({\n className,\n metal = DEFAULT_METAL,\n color,\n ribbons = DEFAULT_RIBBONS,\n speed = DEFAULT_SPEED,\n iridescence = DEFAULT_IRIDESCENCE,\n interactive = true,\n theme = \"auto\",\n}: ShaderMetalProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderMetal(canvas, {\n metal,\n color,\n ribbons,\n speed,\n iridescence,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n metal,\n color,\n ribbons,\n speed,\n iridescence,\n interactive,\n theme,\n })\n }, [metal, color, ribbons, speed, iridescence, interactive, theme])\n\n return (\n \n {/* Firefox can hand a WebGL canvas straight to the system compositor,\n which ignores rounded clips. The mask on the root keeps it in the\n page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/shader-metal.tsx" } diff --git a/public/r/shader-sky-svelte.json b/public/r/shader-sky-svelte.json index 5f34689..5cd6903 100644 --- a/public/r/shader-sky-svelte.json +++ b/public/r/shader-sky-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-sky/shader-sky.svelte", - "content": "\n\n\n\n\n \n \n\n", + "content": "\n\n\n\n\n \n \n\n", "type": "registry:file", "target": "src/lib/components/ui/shader-sky.svelte" } diff --git a/public/r/shader-sky.json b/public/r/shader-sky.json index 405fbad..5046ad7 100644 --- a/public/r/shader-sky.json +++ b/public/r/shader-sky.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-sky/shader-sky.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderSkyTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderSkyOptions = {\n /** Zenith / horizon / cloud / shade hex */\n colors?: string[]\n /** Drift speed. Default 0.1 */\n speed?: number\n /** How thick the clouds read 0–1. Default 0.5 */\n coverage?: number\n /** Cloud strength 0–1. Default 0.9 */\n intensity?: number\n /** How many distinct clouds 0–1. Default 0.5 */\n amount?: number\n /** Cloud size 0–1. Default 0.4 */\n scale?: number\n /** Mix of big and small clouds 0–1. Default 0.7 */\n variation?: number\n /** Follow the pointer gently. Default false */\n interactive?: boolean\n /**\n * Storm flashes that light the clouds from inside, every few seconds.\n * Off under reduced motion. Default false\n */\n lightning?: boolean\n /**\n * Window glass: a transparent dotted film over the sky.\n * Default false\n */\n glass?: boolean\n /** Glass texture cell size in CSS px. Default `7` */\n glassSize?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) **only when `colors` is omitted**, so the stock\n * clear-sky / rain palettes swap with the site theme. A custom palette\n * stays put.\n */\n theme?: ShaderSkyTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderSkyInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Open daylight: zenith / horizon / white smoke / cool shade */\nexport const LIGHT_COLORS = [\"#2478C8\", \"#8ECBF2\", \"#F7FBFF\", \"#C5D8EC\"]\n/** Storm ceiling: light slate / rain horizon / mid cloud / cool shade */\nexport const DARK_COLORS = [\"#9AA3AD\", \"#C8CED4\", \"#5C6570\", \"#3F4750\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#5BA3DC\",\n backgroundImage: [\n \"linear-gradient(180deg, #2478C8 0%, #5BA3DC 48%, #8ECBF2 100%)\",\n \"radial-gradient(28% 18% at 22% 68%, #F7FBFF 0%, transparent 70%)\",\n \"radial-gradient(24% 16% at 74% 42%, #F7FBFF 0%, transparent 68%)\",\n \"radial-gradient(20% 14% at 88% 76%, #C5D8EC 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#A8B0B8\",\n backgroundImage: [\n \"linear-gradient(180deg, #9AA3AD 0%, #B0B7BF 46%, #C8CED4 100%)\",\n \"radial-gradient(32% 22% at 24% 62%, #5C6570 0%, transparent 70%)\",\n \"radial-gradient(26% 18% at 76% 34%, #3F4750 0%, transparent 68%)\",\n \"radial-gradient(22% 16% at 58% 80%, #5C6570 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport function skyFallback(colors: string[] | undefined, dark: boolean) {\n if (colors && colors.length > 0) {\n const c1 = colors[0] ?? LIGHT_COLORS[0]!\n const c2 = colors[1] ?? LIGHT_COLORS[1]!\n const c3 = colors[2] ?? LIGHT_COLORS[2]!\n const c4 = colors[3] ?? LIGHT_COLORS[3]!\n return {\n backgroundColor: c2,\n backgroundImage: [\n `linear-gradient(180deg, ${c1} 0%, ${c2} 100%)`,\n `radial-gradient(28% 18% at 22% 68%, ${c3} 0%, transparent 70%)`,\n `radial-gradient(24% 16% at 74% 42%, ${c3} 0%, transparent 68%)`,\n `radial-gradient(20% 14% at 88% 76%, ${c4} 0%, transparent 70%)`,\n ].join(\", \"),\n }\n }\n return dark ? DARK_FALLBACK : LIGHT_FALLBACK\n}\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\n/**\n * Volumetric cumulus: rays from the viewer march through a slab of cloud\n * between BASE and TOP. Density is 3D noise shaped by a height profile and\n * cut back to a slow 2D coverage map, so clouds billow with clear sky\n * between them. Sunlight is attenuated\n * toward the sun (Beer–Lambert) and scattered with a two-lobe phase, so\n * edges near the sun glow and thick cores go gray underneath.\n */\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform float u_coverage;\nuniform float u_intensity;\nuniform float u_amount;\nuniform float u_scale;\nuniform float u_variation;\nuniform float u_glass;\nuniform float u_glassSize;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform vec2 u_mouse;\nuniform float u_flash;\nuniform vec2 u_flashPos;\nuniform sampler2D u_noise;\n\n// Cloud slab, in world units above the viewer.\nconst float BASE = 1.0;\nconst float TOP = 1.9;\n// Past this distance the layer is haze, not clouds.\nconst float FAR = 34.0;\nconst int STEPS = 36;\nconst int LIGHT_STEPS = 3;\n// Horizon just inside the bottom edge, like a photo taken from a hill.\nconst float TILT = 0.46;\n\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n// Value noise from a 256² random texture: one lookup per sample.\nfloat noise2(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n return texture2D(u_noise, (i + f + 0.5) / 256.0).x;\n}\n\n// 3D value noise: the texture's green channel is red offset by (37, 239),\n// so one lookup returns two neighbouring z slices.\nfloat noise3(vec3 p) {\n vec3 i = floor(p);\n vec3 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n vec2 uv = i.xy + vec2(37.0, 239.0) * i.z + f.xy;\n vec2 rg = texture2D(u_noise, (uv + 0.5) / 256.0).yx;\n return mix(rg.x, rg.y, f.z);\n}\n\nfloat luma(vec3 c) {\n return dot(c, vec3(0.2126, 0.7152, 0.0722));\n}\n\n// Henyey–Greenstein, scaled so isotropic scattering is 1.\nfloat phaseHG(float mu, float g) {\n float g2 = g * g;\n return (1.0 - g2) / pow(1.0 + g2 - 2.0 * g * mu, 1.5);\n}\n\n// Local coverage: a slow 2D weather map splits the sky into distinct\n// clouds with clear gaps between them.\nfloat coverageAt(vec2 xz) {\n float weather = noise2(xz * mix(0.14, 0.32, u_amount) + 13.7);\n float cov = mix(0.22, 0.72, u_coverage);\n return clamp(cov + (weather - 0.5) * mix(1.0, 0.55, u_amount), 0.0, 1.0);\n}\n\n// Cumulus density: 3D fbm shaped by a height profile (flat-ish base,\n// billowing top), then cut back to the local coverage.\nfloat density(vec3 q, float hf, float cov, float t, float fine) {\n float shape = smoothstep(0.0, 0.14, hf) * (1.0 - smoothstep(0.35, 1.0, hf));\n float n = noise3(q) * 0.52;\n n += noise3(q * 2.03 + 3.1) * 0.27;\n n += noise3(q * 4.07 + 7.7) * 0.14;\n n += fine * noise3(q * 8.3 + 1.9) * 0.1;\n n /= 0.93 + fine * 0.1;\n float d = n * shape;\n d = clamp((d - (1.0 - cov)) / max(cov, 0.05), 0.0, 1.0);\n if (fine > 0.5 && d > 0.0) {\n // Wispy erosion at the rims, rising slowly like convection.\n float e = noise3(q * 6.1 + vec3(0.0, -t * 0.6, 0.0));\n d = clamp(d - e * mix(0.08, 0.26, u_variation) * (1.0 - d), 0.0, 1.0);\n }\n return d;\n}\n\nvoid main() {\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float t = u_time * u_speed;\n float cell = max(u_glassSize, 2.0);\n\n vec2 uv = gl_FragCoord.xy / u_resolution.xy;\n vec2 glassLocal = fract(gl_FragCoord.xy / cell) - 0.5;\n if (u_glass > 0.5) {\n uv += glassLocal * 0.0032;\n }\n\n vec2 s = (uv - 0.5) * vec2(aspect, 1.0);\n s += (u_mouse - 0.5) * vec2(0.06, 0.03);\n vec3 dir = normalize(vec3(s.x, s.y + TILT, 1.0));\n vec3 sunDir = normalize(vec3(0.85, 0.95, 1.0));\n float mu = dot(dir, sunDir);\n\n // Storm palettes (cloud darker than the horizon) mute the sun.\n float storm = smoothstep(0.02, -0.12, luma(u_c3) - luma(u_c2));\n float sunAmt = 1.0 - storm * 0.75;\n vec3 sunTint = mix(vec3(1.0, 0.96, 0.88), u_c3, 0.3);\n\n // --- Sky ---------------------------------------------------------------\n // Below the horizon there is only haze.\n float up = clamp(dir.y, 0.0, 1.0);\n vec3 sky = mix(u_c2, u_c1, pow(smoothstep(0.0, 0.85, up), 0.6));\n vec3 haze = mix(u_c2, vec3(1.0), 0.3 * luma(u_c2));\n sky = mix(sky, haze, exp(-up * 9.0) * 0.6);\n float glow = pow(max(mu, 0.0), 8.0) * 0.14 + pow(max(mu, 0.0), 120.0) * 0.3;\n sky += sunTint * glow * sunAmt;\n\n // --- Clouds ------------------------------------------------------------\n float freq = mix(2.2, 0.9, u_scale);\n // Wind carries the field sideways and toward the viewer.\n vec3 drift = vec3(t * 0.5, 0.0, t * 0.2);\n float sigma = mix(10.0, 26.0, u_intensity);\n vec3 sunLight = u_c3 * mix(1.12, 1.0, storm) * mix(vec3(1.0), sunTint, sunAmt * 0.5);\n float phase = mix(phaseHG(mu, -0.2), phaseHG(mu, 0.7), 0.12);\n\n vec3 L = vec3(0.0);\n float T = 1.0;\n float hitAt = 0.0;\n if (dir.y > 0.02) {\n float t0 = BASE / dir.y;\n if (t0 < FAR) {\n float t1 = min(TOP / dir.y, t0 + 5.0);\n float dt = (t1 - t0) / float(STEPS);\n // Jitter the start per pixel so the steps don't band.\n float tt = t0 + dt * hash(gl_FragCoord.xy);\n for (int i = 0; i < STEPS; i++) {\n vec3 p = dir * tt;\n vec3 q = p * freq + drift;\n float hf = (p.y - BASE) / (TOP - BASE);\n float cov = coverageAt(q.xz);\n float d = cov > 0.02 ? density(q, hf, cov, t, 1.0) : 0.0;\n if (d > 0.003) {\n if (hitAt == 0.0) hitAt = tt;\n // March toward the sun for self-shadowing.\n float od = 0.0;\n for (int j = 0; j < LIGHT_STEPS; j++) {\n vec3 lp = p + sunDir * (0.05 + float(j) * 0.12);\n float lh = (lp.y - BASE) / (TOP - BASE);\n od += density(lp * freq + drift, lh, cov, t, 0.0);\n }\n od *= 0.12 * sigma * 0.5;\n // Beer–Lambert with a softer tail standing in for multiple scattering.\n float beer = max(exp(-od), exp(-od * 0.25) * 0.6);\n float powder = 1.0 - exp(-d * 5.0);\n vec3 ambient = mix(u_c4 * mix(0.82, 0.62, storm), mix(u_c1, u_c3, 0.6), hf);\n vec3 lum = sunLight * beer * phase * mix(1.0, powder, 0.35) + ambient * 0.72;\n float dT = exp(-d * sigma * dt);\n L += T * (1.0 - dT) * lum;\n T *= dT;\n if (T < 0.02) break;\n }\n tt += dt;\n }\n // Aerial perspective: distant clouds melt into the horizon haze.\n float fog = 1.0 - exp(-max(hitAt, t0) * 0.05);\n L = mix(L, haze * (1.0 - T), fog * 0.85);\n }\n }\n\n vec3 col = sky * T + L;\n\n // Lightning, lit from inside the clouds it hits.\n if (u_flash > 0.0) {\n vec2 fp = vec2(u_flashPos.x * aspect, u_flashPos.y - 0.5);\n float bloom = exp(-dot(s - fp, s - fp) * 3.2);\n vec3 bolt = vec3(0.86, 0.9, 1.0);\n col += bolt * u_flash * (bloom * (1.0 - T) * 0.95 + 0.05);\n }\n\n if (u_glass > 0.5) {\n float pane = smoothstep(0.50, 0.22, length(glassLocal));\n col *= mix(0.93, 1.0, pane);\n vec2 hl = glassLocal - vec2(-0.16, 0.18);\n col += exp(-dot(hl, hl) * 55.0) * 0.035;\n col = mix(col, vec3(luma(col)), 0.04);\n }\n col += (hash(gl_FragCoord.xy + fract(u_time) * 91.0) - 0.5) * 0.008;\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\n/** Soft clouds hide resolution, so cap the canvas to keep the march cheap. */\nconst MAX_PIXELS = 720_000\nconst NOISE_SIZE = 256\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.14, 0.47, 0.78]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\n/**\n * Seeded random texture for the shader's value noise. Green repeats red\n * offset by (37, 239), which lets one lookup serve two 3D noise slices.\n * Seeded so every visit gets the same sky.\n */\nfunction noiseTexels() {\n const size = NOISE_SIZE\n const data = new Uint8Array(size * size * 4)\n let seed = 0x23d\n const rand = () => {\n seed = (seed + 0x6d2b79f5) | 0\n let r = Math.imul(seed ^ (seed >>> 15), 1 | seed)\n r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r\n return ((r ^ (r >>> 14)) >>> 0) / 4294967296\n }\n for (let i = 0; i < size * size; i++) {\n data[i * 4] = Math.floor(rand() * 256)\n }\n for (let y = 0; y < size; y++) {\n for (let x = 0; x < size; x++) {\n const from = ((y - 239) & (size - 1)) * size + ((x - 37) & (size - 1))\n const i = (y * size + x) * 4\n data[i + 1] = data[from * 4]!\n data[i + 2] = 0\n data[i + 3] = 255\n }\n }\n return data\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderSkyTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderSky: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * WebGL sky for heroes: volumetric clouds drifting across clear blue in\n * light, storm gray in dark, with optional lightning. Optional\n * window-glass film. Pauses off screen and in hidden tabs, and holds a still\n * frame under reduced motion. Recovers from a lost WebGL context.\n */\nexport function createShaderSky(\n canvas: HTMLCanvasElement,\n initial: ShaderSkyOptions = {}\n): ShaderSkyInstance | null {\n let options: Required<\n Pick<\n ShaderSkyOptions,\n | \"speed\"\n | \"coverage\"\n | \"intensity\"\n | \"amount\"\n | \"scale\"\n | \"variation\"\n | \"interactive\"\n | \"lightning\"\n | \"glass\"\n | \"glassSize\"\n | \"theme\"\n >\n > &\n ShaderSkyOptions = {\n speed: 0.1,\n coverage: 0.5,\n intensity: 0.9,\n amount: 0.5,\n scale: 0.4,\n variation: 0.7,\n interactive: false,\n lightning: false,\n glass: false,\n glassSize: 7,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const targetMouse = { x: 0.5, y: 0.5 }\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let noiseTex: WebGLTexture | null = null\n let uResolution: WebGLUniformLocation | null = null\n let uTime: WebGLUniformLocation | null = null\n let uSpeed: WebGLUniformLocation | null = null\n let uCoverage: WebGLUniformLocation | null = null\n let uIntensity: WebGLUniformLocation | null = null\n let uAmount: WebGLUniformLocation | null = null\n let uScale: WebGLUniformLocation | null = null\n let uVariation: WebGLUniformLocation | null = null\n let uGlass: WebGLUniformLocation | null = null\n let uGlassSize: WebGLUniformLocation | null = null\n let uC1: WebGLUniformLocation | null = null\n let uC2: WebGLUniformLocation | null = null\n let uC3: WebGLUniformLocation | null = null\n let uC4: WebGLUniformLocation | null = null\n let uMouse: WebGLUniformLocation | null = null\n let uFlash: WebGLUniformLocation | null = null\n let uFlashPos: WebGLUniformLocation | null = null\n\n // Builds every GL resource; runs at startup and again after a lost context\n // comes back, since a restored context starts empty.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderSky: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n noiseTex = gl.createTexture()\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, noiseTex)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n NOISE_SIZE,\n NOISE_SIZE,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n noiseTexels()\n )\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)\n gl.uniform1i(gl.getUniformLocation(program, \"u_noise\"), 0)\n\n uResolution = gl.getUniformLocation(program, \"u_resolution\")\n uTime = gl.getUniformLocation(program, \"u_time\")\n uSpeed = gl.getUniformLocation(program, \"u_speed\")\n uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n uAmount = gl.getUniformLocation(program, \"u_amount\")\n uScale = gl.getUniformLocation(program, \"u_scale\")\n uVariation = gl.getUniformLocation(program, \"u_variation\")\n uGlass = gl.getUniformLocation(program, \"u_glass\")\n uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n uC1 = gl.getUniformLocation(program, \"u_c1\")\n uC2 = gl.getUniformLocation(program, \"u_c2\")\n uC3 = gl.getUniformLocation(program, \"u_c3\")\n uC4 = gl.getUniformLocation(program, \"u_c4\")\n uMouse = gl.getUniformLocation(program, \"u_mouse\")\n uFlash = gl.getUniformLocation(program, \"u_flash\")\n uFlashPos = gl.getUniformLocation(program, \"u_flashPos\")\n return true\n }\n if (!initGl()) return null\n\n let raf = 0\n let destroyed = false\n // The browser dropped the GL context; nothing touches gl until it returns.\n let lost = false\n let onScreen = true\n let last = 0\n // Seconds of motion so far; it only advances while the sky is moving, so it\n // resumes where it left off.\n let clock = 0\n\n // One strike = a bright flicker and a softer echo, then 4–10s of quiet.\n const strike = { at: 0, next: 0, x: 0, y: 0 }\n const pulse = (s: number) => (s < 0 ? 0 : Math.exp(-s * 14))\n const flashAt = (now: number) => {\n if (!options.lightning || reduce) {\n strike.next = 0\n return 0\n }\n if (strike.next === 0) strike.next = now + 1500 + Math.random() * 2500\n if (now >= strike.next) {\n strike.at = now\n strike.next = now + 4000 + Math.random() * 6000\n strike.x = (Math.random() - 0.5) * 0.9\n strike.y = 0.55 + Math.random() * 0.35\n }\n if (strike.at === 0) return 0\n const s = (now - strike.at) / 1000\n return Math.min(1, pulse(s) + 0.65 * pulse(s - 0.16))\n }\n\n const shown = () => onScreen && document.visibilityState !== \"hidden\"\n const moving = () => shown() && !reduce\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // Device pixels per CSS pixel actually rendered (after the pixel cap).\n let pixelScale = 1\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n pixelScale = Math.min(dpr, Math.sqrt(MAX_PIXELS / (w * h)))\n canvas.width = Math.max(1, Math.floor(w * pixelScale))\n canvas.height = Math.max(1, Math.floor(h * pixelScale))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n if (!lost) gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n targetMouse.x = (e.clientX - rect.left) / rect.width\n targetMouse.y = 1 - (e.clientY - rect.top) / rect.height\n if (shown()) wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n raf = 0\n if (destroyed || lost) return\n // Capped so a frame queued before the tab was hidden doesn't jump ahead,\n // but loose enough that slow devices still keep real time.\n const dt = Math.min(0.25, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n if (live) clock += dt\n\n const time = clock\n const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n const palette = [0, 1, 2, 3].map((i) =>\n hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)\n )\n\n mouse.x += (targetMouse.x - mouse.x) * 0.03\n mouse.y += (targetMouse.y - mouse.y) * 0.03\n // The pointer shifts the clouds, so a still frame keeps easing toward it.\n const easing =\n options.interactive &&\n Math.abs(targetMouse.x - mouse.x) + Math.abs(targetMouse.y - mouse.y) >\n 0.001\n\n const glassPx = Math.max(2, options.glassSize ?? 7) * pixelScale\n\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.1))\n gl.uniform1f(uCoverage, Math.min(1, Math.max(0, options.coverage ?? 0.5)))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.9)))\n gl.uniform1f(uAmount, Math.min(1, Math.max(0, options.amount ?? 0.5)))\n gl.uniform1f(uScale, Math.min(1, Math.max(0, options.scale ?? 0.4)))\n gl.uniform1f(uVariation, Math.min(1, Math.max(0, options.variation ?? 0.7)))\n gl.uniform1f(uGlass, options.glass ? 1 : 0)\n gl.uniform1f(uGlassSize, glassPx)\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n gl.uniform3f(uC4, palette[3]![0], palette[3]![1], palette[3]![2])\n gl.uniform2f(\n uMouse,\n options.interactive ? mouse.x : 0.5,\n options.interactive ? mouse.y : 0.5\n )\n\n gl.uniform1f(uFlash, flashAt(clock * 1000))\n gl.uniform2f(uFlashPos, strike.x, strike.y)\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n if (live || (easing && shown())) raf = requestAnimationFrame(tick)\n }\n\n // Run after `tick` exists, since both can wake the loop.\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n onReduce()\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n // Without preventDefault the browser never restores a lost context.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(noiseTex)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderSkyProps = Omit & {\n className?: string\n}\n\n/**\n * WebGL sky for heroes: clear blue with drifting clouds in light,\n * storm gray in dark, with optional lightning. Optional window-glass film.\n */\nexport function ShaderSky({\n className,\n colors,\n speed = 0.1,\n coverage = 0.5,\n intensity = 0.9,\n amount = 0.5,\n scale = 0.4,\n variation = 0.7,\n interactive = false,\n lightning = false,\n glass = false,\n glassSize = 7,\n theme = \"auto\",\n}: ShaderSkyProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderSky(canvas, {\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n lightning,\n glass,\n glassSize,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n lightning,\n glass,\n glassSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n lightning,\n glass,\n glassSize,\n theme,\n ])\n\n const fallback = skyFallback(colors, isDark)\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderSkyTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderSkyOptions = {\n /** Zenith / horizon / cloud / shade hex */\n colors?: string[]\n /** Drift speed. Default 0.1 */\n speed?: number\n /** How thick the clouds read 0–1. Default 0.5 */\n coverage?: number\n /** Cloud strength 0–1. Default 0.9 */\n intensity?: number\n /** How many distinct clouds 0–1. Default 0.5 */\n amount?: number\n /** Cloud size 0–1. Default 0.4 */\n scale?: number\n /** Mix of big and small clouds 0–1. Default 0.7 */\n variation?: number\n /** Follow the pointer gently. Default false */\n interactive?: boolean\n /**\n * Storm flashes that light the clouds from inside, every few seconds.\n * Off under reduced motion. Default false\n */\n lightning?: boolean\n /**\n * Window glass: a transparent dotted film over the sky.\n * Default false\n */\n glass?: boolean\n /** Glass texture cell size in CSS px. Default `7` */\n glassSize?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) **only when `colors` is omitted**, so the stock\n * clear-sky / rain palettes swap with the site theme. A custom palette\n * stays put.\n */\n theme?: ShaderSkyTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderSkyInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Open daylight: zenith / horizon / white smoke / cool shade */\nexport const LIGHT_COLORS = [\"#2478C8\", \"#8ECBF2\", \"#F7FBFF\", \"#C5D8EC\"]\n/** Storm ceiling: light slate / rain horizon / mid cloud / cool shade */\nexport const DARK_COLORS = [\"#9AA3AD\", \"#C8CED4\", \"#5C6570\", \"#3F4750\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#5BA3DC\",\n backgroundImage: [\n \"linear-gradient(180deg, #2478C8 0%, #5BA3DC 48%, #8ECBF2 100%)\",\n \"radial-gradient(28% 18% at 22% 68%, #F7FBFF 0%, transparent 70%)\",\n \"radial-gradient(24% 16% at 74% 42%, #F7FBFF 0%, transparent 68%)\",\n \"radial-gradient(20% 14% at 88% 76%, #C5D8EC 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#A8B0B8\",\n backgroundImage: [\n \"linear-gradient(180deg, #9AA3AD 0%, #B0B7BF 46%, #C8CED4 100%)\",\n \"radial-gradient(32% 22% at 24% 62%, #5C6570 0%, transparent 70%)\",\n \"radial-gradient(26% 18% at 76% 34%, #3F4750 0%, transparent 68%)\",\n \"radial-gradient(22% 16% at 58% 80%, #5C6570 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport function skyFallback(colors: string[] | undefined, dark: boolean) {\n if (colors && colors.length > 0) {\n const c1 = colors[0] ?? LIGHT_COLORS[0]!\n const c2 = colors[1] ?? LIGHT_COLORS[1]!\n const c3 = colors[2] ?? LIGHT_COLORS[2]!\n const c4 = colors[3] ?? LIGHT_COLORS[3]!\n return {\n backgroundColor: c2,\n backgroundImage: [\n `linear-gradient(180deg, ${c1} 0%, ${c2} 100%)`,\n `radial-gradient(28% 18% at 22% 68%, ${c3} 0%, transparent 70%)`,\n `radial-gradient(24% 16% at 74% 42%, ${c3} 0%, transparent 68%)`,\n `radial-gradient(20% 14% at 88% 76%, ${c4} 0%, transparent 70%)`,\n ].join(\", \"),\n }\n }\n return dark ? DARK_FALLBACK : LIGHT_FALLBACK\n}\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\n/**\n * Volumetric cumulus: rays from the viewer march through a slab of cloud\n * between BASE and TOP. Density is 3D noise shaped by a height profile and\n * cut back to a slow 2D coverage map, so clouds billow with clear sky\n * between them. Sunlight is attenuated\n * toward the sun (Beer–Lambert) and scattered with a two-lobe phase, so\n * edges near the sun glow and thick cores go gray underneath.\n */\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform float u_coverage;\nuniform float u_intensity;\nuniform float u_amount;\nuniform float u_scale;\nuniform float u_variation;\nuniform float u_glass;\nuniform float u_glassSize;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform vec2 u_mouse;\nuniform float u_flash;\nuniform vec2 u_flashPos;\nuniform sampler2D u_noise;\n\n// Cloud slab, in world units above the viewer.\nconst float BASE = 1.0;\nconst float TOP = 1.9;\n// Past this distance the layer is haze, not clouds.\nconst float FAR = 34.0;\nconst int STEPS = 36;\nconst int LIGHT_STEPS = 3;\n// Horizon just inside the bottom edge, like a photo taken from a hill.\nconst float TILT = 0.46;\n\nfloat hash(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n// Value noise from a 256² random texture: one lookup per sample.\nfloat noise2(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n return texture2D(u_noise, (i + f + 0.5) / 256.0).x;\n}\n\n// 3D value noise: the texture's green channel is red offset by (37, 239),\n// so one lookup returns two neighbouring z slices.\nfloat noise3(vec3 p) {\n vec3 i = floor(p);\n vec3 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n vec2 uv = i.xy + vec2(37.0, 239.0) * i.z + f.xy;\n vec2 rg = texture2D(u_noise, (uv + 0.5) / 256.0).yx;\n return mix(rg.x, rg.y, f.z);\n}\n\nfloat luma(vec3 c) {\n return dot(c, vec3(0.2126, 0.7152, 0.0722));\n}\n\n// Henyey–Greenstein, scaled so isotropic scattering is 1.\nfloat phaseHG(float mu, float g) {\n float g2 = g * g;\n return (1.0 - g2) / pow(1.0 + g2 - 2.0 * g * mu, 1.5);\n}\n\n// Local coverage: a slow 2D weather map splits the sky into distinct\n// clouds with clear gaps between them.\nfloat coverageAt(vec2 xz) {\n float weather = noise2(xz * mix(0.14, 0.32, u_amount) + 13.7);\n float cov = mix(0.22, 0.72, u_coverage);\n return clamp(cov + (weather - 0.5) * mix(1.0, 0.55, u_amount), 0.0, 1.0);\n}\n\n// Cumulus density: 3D fbm shaped by a height profile (flat-ish base,\n// billowing top), then cut back to the local coverage.\nfloat density(vec3 q, float hf, float cov, float t, float fine) {\n float shape = smoothstep(0.0, 0.14, hf) * (1.0 - smoothstep(0.35, 1.0, hf));\n float n = noise3(q) * 0.52;\n n += noise3(q * 2.03 + 3.1) * 0.27;\n n += noise3(q * 4.07 + 7.7) * 0.14;\n n += fine * noise3(q * 8.3 + 1.9) * 0.1;\n n /= 0.93 + fine * 0.1;\n float d = n * shape;\n d = clamp((d - (1.0 - cov)) / max(cov, 0.05), 0.0, 1.0);\n if (fine > 0.5 && d > 0.0) {\n // Wispy erosion at the rims, rising slowly like convection.\n float e = noise3(q * 6.1 + vec3(0.0, -t * 0.6, 0.0));\n d = clamp(d - e * mix(0.08, 0.26, u_variation) * (1.0 - d), 0.0, 1.0);\n }\n return d;\n}\n\nvoid main() {\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float t = u_time * u_speed;\n float cell = max(u_glassSize, 2.0);\n\n vec2 uv = gl_FragCoord.xy / u_resolution.xy;\n vec2 glassLocal = fract(gl_FragCoord.xy / cell) - 0.5;\n if (u_glass > 0.5) {\n uv += glassLocal * 0.0032;\n }\n\n vec2 s = (uv - 0.5) * vec2(aspect, 1.0);\n s += (u_mouse - 0.5) * vec2(0.06, 0.03);\n vec3 dir = normalize(vec3(s.x, s.y + TILT, 1.0));\n vec3 sunDir = normalize(vec3(0.85, 0.95, 1.0));\n float mu = dot(dir, sunDir);\n\n // Storm palettes (cloud darker than the horizon) mute the sun.\n float storm = smoothstep(0.02, -0.12, luma(u_c3) - luma(u_c2));\n float sunAmt = 1.0 - storm * 0.75;\n vec3 sunTint = mix(vec3(1.0, 0.96, 0.88), u_c3, 0.3);\n\n // --- Sky ---------------------------------------------------------------\n // Below the horizon there is only haze.\n float up = clamp(dir.y, 0.0, 1.0);\n vec3 sky = mix(u_c2, u_c1, pow(smoothstep(0.0, 0.85, up), 0.6));\n vec3 haze = mix(u_c2, vec3(1.0), 0.3 * luma(u_c2));\n sky = mix(sky, haze, exp(-up * 9.0) * 0.6);\n float glow = pow(max(mu, 0.0), 8.0) * 0.14 + pow(max(mu, 0.0), 120.0) * 0.3;\n sky += sunTint * glow * sunAmt;\n\n // --- Clouds ------------------------------------------------------------\n float freq = mix(2.2, 0.9, u_scale);\n // Wind carries the field sideways and toward the viewer.\n vec3 drift = vec3(t * 0.5, 0.0, t * 0.2);\n float sigma = mix(10.0, 26.0, u_intensity);\n vec3 sunLight = u_c3 * mix(1.12, 1.0, storm) * mix(vec3(1.0), sunTint, sunAmt * 0.5);\n float phase = mix(phaseHG(mu, -0.2), phaseHG(mu, 0.7), 0.12);\n\n vec3 L = vec3(0.0);\n float T = 1.0;\n float hitAt = 0.0;\n if (dir.y > 0.02) {\n float t0 = BASE / dir.y;\n if (t0 < FAR) {\n float t1 = min(TOP / dir.y, t0 + 5.0);\n float dt = (t1 - t0) / float(STEPS);\n // Jitter the start per pixel so the steps don't band.\n float tt = t0 + dt * hash(gl_FragCoord.xy);\n for (int i = 0; i < STEPS; i++) {\n vec3 p = dir * tt;\n vec3 q = p * freq + drift;\n float hf = (p.y - BASE) / (TOP - BASE);\n float cov = coverageAt(q.xz);\n float d = cov > 0.02 ? density(q, hf, cov, t, 1.0) : 0.0;\n if (d > 0.003) {\n if (hitAt == 0.0) hitAt = tt;\n // March toward the sun for self-shadowing.\n float od = 0.0;\n for (int j = 0; j < LIGHT_STEPS; j++) {\n vec3 lp = p + sunDir * (0.05 + float(j) * 0.12);\n float lh = (lp.y - BASE) / (TOP - BASE);\n od += density(lp * freq + drift, lh, cov, t, 0.0);\n }\n od *= 0.12 * sigma * 0.5;\n // Beer–Lambert with a softer tail standing in for multiple scattering.\n float beer = max(exp(-od), exp(-od * 0.25) * 0.6);\n float powder = 1.0 - exp(-d * 5.0);\n vec3 ambient = mix(u_c4 * mix(0.82, 0.62, storm), mix(u_c1, u_c3, 0.6), hf);\n vec3 lum = sunLight * beer * phase * mix(1.0, powder, 0.35) + ambient * 0.72;\n float dT = exp(-d * sigma * dt);\n L += T * (1.0 - dT) * lum;\n T *= dT;\n if (T < 0.02) break;\n }\n tt += dt;\n }\n // Aerial perspective: distant clouds melt into the horizon haze.\n float fog = 1.0 - exp(-max(hitAt, t0) * 0.05);\n L = mix(L, haze * (1.0 - T), fog * 0.85);\n }\n }\n\n vec3 col = sky * T + L;\n\n // Lightning, lit from inside the clouds it hits.\n if (u_flash > 0.0) {\n vec2 fp = vec2(u_flashPos.x * aspect, u_flashPos.y - 0.5);\n float bloom = exp(-dot(s - fp, s - fp) * 3.2);\n vec3 bolt = vec3(0.86, 0.9, 1.0);\n col += bolt * u_flash * (bloom * (1.0 - T) * 0.95 + 0.05);\n }\n\n if (u_glass > 0.5) {\n float pane = smoothstep(0.50, 0.22, length(glassLocal));\n col *= mix(0.93, 1.0, pane);\n vec2 hl = glassLocal - vec2(-0.16, 0.18);\n col += exp(-dot(hl, hl) * 55.0) * 0.035;\n col = mix(col, vec3(luma(col)), 0.04);\n }\n col += (hash(gl_FragCoord.xy + fract(u_time) * 91.0) - 0.5) * 0.008;\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\n/** Soft clouds hide resolution, so cap the canvas to keep the march cheap. */\nconst MAX_PIXELS = 720_000\nconst NOISE_SIZE = 256\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.14, 0.47, 0.78]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\n/**\n * Seeded random texture for the shader's value noise. Green repeats red\n * offset by (37, 239), which lets one lookup serve two 3D noise slices.\n * Seeded so every visit gets the same sky.\n */\nfunction noiseTexels() {\n const size = NOISE_SIZE\n const data = new Uint8Array(size * size * 4)\n let seed = 0x23d\n const rand = () => {\n seed = (seed + 0x6d2b79f5) | 0\n let r = Math.imul(seed ^ (seed >>> 15), 1 | seed)\n r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r\n return ((r ^ (r >>> 14)) >>> 0) / 4294967296\n }\n for (let i = 0; i < size * size; i++) {\n data[i * 4] = Math.floor(rand() * 256)\n }\n for (let y = 0; y < size; y++) {\n for (let x = 0; x < size; x++) {\n const from = ((y - 239) & (size - 1)) * size + ((x - 37) & (size - 1))\n const i = (y * size + x) * 4\n data[i + 1] = data[from * 4]!\n data[i + 2] = 0\n data[i + 3] = 255\n }\n }\n return data\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderSkyTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderSky: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * WebGL sky for heroes: volumetric clouds drifting across clear blue in\n * light, storm gray in dark, with optional lightning. Optional\n * window-glass film. Pauses off screen and in hidden tabs, and holds a still\n * frame under reduced motion. Recovers from a lost WebGL context.\n */\nexport function createShaderSky(\n canvas: HTMLCanvasElement,\n initial: ShaderSkyOptions = {}\n): ShaderSkyInstance | null {\n let options: Required<\n Pick<\n ShaderSkyOptions,\n | \"speed\"\n | \"coverage\"\n | \"intensity\"\n | \"amount\"\n | \"scale\"\n | \"variation\"\n | \"interactive\"\n | \"lightning\"\n | \"glass\"\n | \"glassSize\"\n | \"theme\"\n >\n > &\n ShaderSkyOptions = {\n speed: 0.1,\n coverage: 0.5,\n intensity: 0.9,\n amount: 0.5,\n scale: 0.4,\n variation: 0.7,\n interactive: false,\n lightning: false,\n glass: false,\n glassSize: 7,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const targetMouse = { x: 0.5, y: 0.5 }\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n let vs: WebGLShader | null = null\n let fs: WebGLShader | null = null\n let program: WebGLProgram | null = null\n let buf: WebGLBuffer | null = null\n let noiseTex: WebGLTexture | null = null\n let uResolution: WebGLUniformLocation | null = null\n let uTime: WebGLUniformLocation | null = null\n let uSpeed: WebGLUniformLocation | null = null\n let uCoverage: WebGLUniformLocation | null = null\n let uIntensity: WebGLUniformLocation | null = null\n let uAmount: WebGLUniformLocation | null = null\n let uScale: WebGLUniformLocation | null = null\n let uVariation: WebGLUniformLocation | null = null\n let uGlass: WebGLUniformLocation | null = null\n let uGlassSize: WebGLUniformLocation | null = null\n let uC1: WebGLUniformLocation | null = null\n let uC2: WebGLUniformLocation | null = null\n let uC3: WebGLUniformLocation | null = null\n let uC4: WebGLUniformLocation | null = null\n let uMouse: WebGLUniformLocation | null = null\n let uFlash: WebGLUniformLocation | null = null\n let uFlashPos: WebGLUniformLocation | null = null\n\n // Builds every GL resource; runs at startup and again after a lost context\n // comes back, since a restored context starts empty.\n const initGl = () => {\n vs = compile(gl, gl.VERTEX_SHADER, VERT)\n fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return false\n }\n\n program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderSky: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return false\n }\n gl.useProgram(program)\n\n buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n noiseTex = gl.createTexture()\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, noiseTex)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n NOISE_SIZE,\n NOISE_SIZE,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n noiseTexels()\n )\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)\n gl.uniform1i(gl.getUniformLocation(program, \"u_noise\"), 0)\n\n uResolution = gl.getUniformLocation(program, \"u_resolution\")\n uTime = gl.getUniformLocation(program, \"u_time\")\n uSpeed = gl.getUniformLocation(program, \"u_speed\")\n uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n uAmount = gl.getUniformLocation(program, \"u_amount\")\n uScale = gl.getUniformLocation(program, \"u_scale\")\n uVariation = gl.getUniformLocation(program, \"u_variation\")\n uGlass = gl.getUniformLocation(program, \"u_glass\")\n uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n uC1 = gl.getUniformLocation(program, \"u_c1\")\n uC2 = gl.getUniformLocation(program, \"u_c2\")\n uC3 = gl.getUniformLocation(program, \"u_c3\")\n uC4 = gl.getUniformLocation(program, \"u_c4\")\n uMouse = gl.getUniformLocation(program, \"u_mouse\")\n uFlash = gl.getUniformLocation(program, \"u_flash\")\n uFlashPos = gl.getUniformLocation(program, \"u_flashPos\")\n return true\n }\n if (!initGl()) return null\n\n let raf = 0\n let destroyed = false\n // The browser dropped the GL context; nothing touches gl until it returns.\n let lost = false\n let onScreen = true\n let last = 0\n // Seconds of motion so far; it only advances while the sky is moving, so it\n // resumes where it left off.\n let clock = 0\n\n // One strike = a bright flicker and a softer echo, then 4–10s of quiet.\n const strike = { at: 0, next: 0, x: 0, y: 0 }\n const pulse = (s: number) => (s < 0 ? 0 : Math.exp(-s * 14))\n const flashAt = (now: number) => {\n if (!options.lightning || reduce) {\n strike.next = 0\n return 0\n }\n if (strike.next === 0) strike.next = now + 1500 + Math.random() * 2500\n if (now >= strike.next) {\n strike.at = now\n strike.next = now + 4000 + Math.random() * 6000\n strike.x = (Math.random() - 0.5) * 0.9\n strike.y = 0.55 + Math.random() * 0.35\n }\n if (strike.at === 0) return 0\n const s = (now - strike.at) / 1000\n return Math.min(1, pulse(s) + 0.65 * pulse(s - 0.16))\n }\n\n const shown = () => onScreen && document.visibilityState !== \"hidden\"\n const moving = () => shown() && !reduce\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n // Device pixels per CSS pixel actually rendered (after the pixel cap).\n let pixelScale = 1\n /** Sizes the canvas to its parent; true when the backing store changed. */\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return false\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return false\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n pixelScale = Math.min(dpr, Math.sqrt(MAX_PIXELS / (w * h)))\n const width = Math.max(1, Math.floor(w * pixelScale))\n const height = Math.max(1, Math.floor(h * pixelScale))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n wake()\n // Assigning a size clears the canvas, even the same size.\n if (width === canvas.width && height === canvas.height) return false\n canvas.width = width\n canvas.height = height\n if (!lost) gl.viewport(0, 0, canvas.width, canvas.height)\n return true\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n targetMouse.x = (e.clientX - rect.left) / rect.width\n targetMouse.y = 1 - (e.clientY - rect.top) / rect.height\n if (shown()) wake()\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n raf = 0\n if (destroyed || lost) return\n // Capped so a frame queued before the tab was hidden doesn't jump ahead,\n // but loose enough that slow devices still keep real time.\n const dt = Math.min(0.25, Math.max(0, (now - last) / 1000))\n last = now\n const live = moving()\n if (live) clock += dt\n\n const time = clock\n const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n const palette = [0, 1, 2, 3].map((i) =>\n hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)\n )\n\n mouse.x += (targetMouse.x - mouse.x) * 0.03\n mouse.y += (targetMouse.y - mouse.y) * 0.03\n // The pointer shifts the clouds, so a still frame keeps easing toward it.\n const easing =\n options.interactive &&\n Math.abs(targetMouse.x - mouse.x) + Math.abs(targetMouse.y - mouse.y) >\n 0.001\n\n const glassPx = Math.max(2, options.glassSize ?? 7) * pixelScale\n\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.1))\n gl.uniform1f(uCoverage, Math.min(1, Math.max(0, options.coverage ?? 0.5)))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.9)))\n gl.uniform1f(uAmount, Math.min(1, Math.max(0, options.amount ?? 0.5)))\n gl.uniform1f(uScale, Math.min(1, Math.max(0, options.scale ?? 0.4)))\n gl.uniform1f(uVariation, Math.min(1, Math.max(0, options.variation ?? 0.7)))\n gl.uniform1f(uGlass, options.glass ? 1 : 0)\n gl.uniform1f(uGlassSize, glassPx)\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n gl.uniform3f(uC4, palette[3]![0], palette[3]![1], palette[3]![2])\n gl.uniform2f(\n uMouse,\n options.interactive ? mouse.x : 0.5,\n options.interactive ? mouse.y : 0.5\n )\n\n gl.uniform1f(uFlash, flashAt(clock * 1000))\n gl.uniform2f(uFlashPos, strike.x, strike.y)\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n if (live || (easing && shown())) raf = requestAnimationFrame(tick)\n }\n\n /**\n * Draws now: resizing cleared the canvas, and waiting for the next frame\n * would show it blank for one.\n */\n const redraw = () => {\n if (destroyed || lost) return\n cancelAnimationFrame(raf)\n raf = 0\n last = performance.now()\n tick(last)\n }\n\n // Run after `tick` exists, since both can wake the loop.\n resize()\n const ro = new ResizeObserver(() => {\n if (resize()) redraw()\n })\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n onReduce()\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n // Without preventDefault the browser never restores a lost context.\n const onLost = (e: Event) => {\n e.preventDefault()\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed || !initGl()) return\n lost = false\n gl.viewport(0, 0, canvas.width, canvas.height)\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(noiseTex)\n // Free the GPU context once the canvas has left the page; browsers cap\n // live contexts at about 16. A canvas still in the page (a React\n // Strict Mode replay) keeps it, so a new engine can reuse it.\n const lose = gl.getExtension(\"WEBGL_lose_context\")\n queueMicrotask(() => {\n if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()\n })\n },\n }\n}\n\nexport type ShaderSkyProps = Omit & {\n className?: string\n}\n\n/**\n * WebGL sky for heroes: clear blue with drifting clouds in light,\n * storm gray in dark, with optional lightning. Optional window-glass film.\n */\nexport function ShaderSky({\n className,\n colors,\n speed = 0.1,\n coverage = 0.5,\n intensity = 0.9,\n amount = 0.5,\n scale = 0.4,\n variation = 0.7,\n interactive = false,\n lightning = false,\n glass = false,\n glassSize = 7,\n theme = \"auto\",\n}: ShaderSkyProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderSky(canvas, {\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n lightning,\n glass,\n glassSize,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n lightning,\n glass,\n glassSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n lightning,\n glass,\n glassSize,\n theme,\n ])\n\n const fallback = skyFallback(colors, isDark)\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/shader-sky.tsx" } diff --git a/registry.json b/registry.json index 81d5baf..f96b43a 100644 --- a/registry.json +++ b/registry.json @@ -8,6 +8,7 @@ "registry/dithered-404/registry.json", "registry/folio/registry.json", "registry/gooey-color-picker/registry.json", + "registry/iso-lattice/registry.json", "registry/live-orb/registry.json", "registry/logo-burst/registry.json", "registry/phosphor-score/registry.json", diff --git a/registry/ascii-fluid/ascii-fluid-demo.tsx b/registry/ascii-fluid/ascii-fluid-demo.tsx index 9b8ecce..929ba4f 100644 --- a/registry/ascii-fluid/ascii-fluid-demo.tsx +++ b/registry/ascii-fluid/ascii-fluid-demo.tsx @@ -86,7 +86,7 @@ export function AsciiFluidDemo() { title="ASCII Fluid" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    { + if (destroyed || lost) return + cancelAnimationFrame(raf) + raf = 0 + last = performance.now() + tick(last) + } + + /** Returns whether the backing size changed (which clears the canvas). */ const resize = () => { const parent = canvas.parentElement - if (!parent) return + if (!parent) return false const dpr = Math.min(window.devicePixelRatio || 1, 2) const w = parent.clientWidth const h = parent.clientHeight - if (w <= 0 || h <= 0) return - canvas.width = Math.max(1, Math.floor(w * dpr)) - canvas.height = Math.max(1, Math.floor(h * dpr)) + if (w <= 0 || h <= 0) return false + const width = Math.max(1, Math.floor(w * dpr)) + const height = Math.max(1, Math.floor(h * dpr)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` + if (width === canvas.width && height === canvas.height) return false + canvas.width = width + canvas.height = height + return true } resize() // Resizing clears the canvas, so redraw even when the loop is idle. const ro = new ResizeObserver(() => { - resize() - wake() + if (resize()) redraw() + else wake() }) if (canvas.parentElement) ro.observe(canvas.parentElement) diff --git a/registry/ascii-logo/ascii-logo-demo.tsx b/registry/ascii-logo/ascii-logo-demo.tsx index b8db8d9..f40543e 100644 --- a/registry/ascii-logo/ascii-logo-demo.tsx +++ b/registry/ascii-logo/ascii-logo-demo.tsx @@ -15,51 +15,38 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { AsciiLogo, + DARK_INK, + LIGHT_INK, MAX_TEXT_LETTERS, clampAsciiLogoText, type AsciiLogoPhase, } from "@/registry/ascii-logo/ascii-logo" -const LIGHT = { color: "#3F3F46", backgroundColor: "#FAFAFA" } -const DARK = { color: "#A1A1AA", backgroundColor: "#09090B" } - -type ThemePalette = { color: string; backgroundColor: string } type SourceMode = "text" | "mark" const HINT: Record = { - logo: "Move around it · click to scatter", + logo: "Move around it, click to scatter", scattered: "Click to drop", fallen: "Click to gather", - returning: "Gathering…", + returning: "Gathering", } function norm(hex: string) { return hex.trim().toUpperCase() } -function matchesPalette( - color: string, - backgroundColor: string, - palette: ThemePalette -) { - return ( - norm(color) === norm(palette.color) && - norm(backgroundColor) === norm(palette.backgroundColor) - ) -} - -function isStockThemePalette(color: string, backgroundColor: string) { - return ( - matchesPalette(color, backgroundColor, LIGHT) || - matchesPalette(color, backgroundColor, DARK) - ) +/** The stock inks swap with the theme; a custom one stays put. */ +function isStockInk(color: string) { + return norm(color) === norm(LIGHT_INK) || norm(color) === norm(DARK_INK) } export function AsciiLogoDemo() { const theme = useHydratedTheme() - const palette: ThemePalette = theme === "dark" ? DARK : LIGHT + const ink = theme === "dark" ? DARK_INK : LIGHT_INK const [phase, setPhase] = useState("logo") const [source, setSource] = useState("text") + // Remounts the logo so the assemble flight plays again. + const [take, setTake] = useState(0) const defaults = useMemo( () => ({ @@ -69,10 +56,9 @@ export function AsciiLogoDemo() { gravity: 0.14, bounce: 0.28, interactive: true, - color: palette.color, - backgroundColor: palette.backgroundColor, + color: ink, }), - [palette.color, palette.backgroundColor] + [ink] ) const { props, updateProp, resetProps, hasChanges, setProps } = @@ -80,17 +66,12 @@ export function AsciiLogoDemo() { useLayoutEffect(() => { setProps((prev) => { - if (!isStockThemePalette(prev.color, prev.backgroundColor)) return prev - if (matchesPalette(prev.color, prev.backgroundColor, palette)) return prev - return { - ...prev, - color: palette.color, - backgroundColor: palette.backgroundColor, - } + if (!isStockInk(prev.color) || norm(prev.color) === norm(ink)) return prev + return { ...prev, color: ink } }) - }, [palette, setProps]) + }, [ink, setProps]) - const useAutoTheme = isStockThemePalette(props.color, props.backgroundColor) + const stockInk = isStockInk(props.color) return ( <> @@ -98,8 +79,9 @@ export function AsciiLogoDemo() { title="ASCII Logo" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    -

    - {props.interactive ? HINT[phase] : "Interaction off"} -

    +
    + {props.interactive ? HINT[phase] : "Interaction off"} + +
    @@ -135,8 +124,7 @@ export function AsciiLogoDemo() { gravity: props.gravity, bounce: props.bounce, interactive: props.interactive ? undefined : false, - color: useAutoTheme ? undefined : props.color, - backgroundColor: useAutoTheme ? undefined : props.backgroundColor, + color: stockInk ? undefined : props.color, }} >
    @@ -197,11 +185,6 @@ export function AsciiLogoDemo() { value={props.color} onChange={(v) => updateProp("color", v)} /> - updateProp("backgroundColor", v)} - /> 0 ? charset : DEFAULT_CHARSET - return pool[Math.floor(Math.random() * pool.length)] ?? "#" +/** + * Calls `onChange` whenever the page theme may have changed (the `html` + * class, `data-theme` or `style`, or the system preference). Returns the + * unsubscribe. + */ +export function watchTheme(onChange: () => void): () => void { + if (typeof document === "undefined") return () => {} + const mo = new MutationObserver(onChange) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mq = window.matchMedia("(prefers-color-scheme: dark)") + mq.addEventListener("change", onChange) + return () => { + mo.disconnect() + mq.removeEventListener("change", onChange) + } +} + +function pickChar(pool: string, fallback: string) { + const chars = pool.length > 0 ? pool : fallback + return chars[Math.floor(Math.random() * chars.length)] ?? "#" +} + +function charFor(weight: number, charset: string, edgeCharset: string) { + if (weight < EDGE_WEIGHT) return pickChar(edgeCharset, EDGE_CHARSET) + return pickChar(charset, DEFAULT_CHARSET) } function loadImage(src: string): Promise { @@ -158,9 +219,35 @@ function staggerCells(cells: AsciiCell[], staggerFrames: number) { } } +/** Glyphs start off stage, spread around the frame, and ease home. */ +function flingOffStage(cells: AsciiCell[], cols: number, rows: number) { + for (const cell of cells) { + const side = Math.random() + if (side < 0.5) { + // Above or below the frame, anywhere across. + cell.offsetX = (Math.random() * 2 - 1) * cols * 0.6 + cell.offsetY = + side < 0.25 + ? -(cell.row + Math.random() * rows) + : rows + Math.random() * rows * 0.5 + } else { + // Left or right of the frame. + cell.offsetX = + side < 0.75 + ? -(cell.col + Math.random() * cols) + : cols + Math.random() * cols * 0.5 + cell.offsetY = (Math.random() * 2 - 1) * rows * 0.6 + } + cell.spin = (Math.random() * 2 - 1) * Math.PI + cell.spinSpeed = 0 + cell.fallSpeed = 0 + } +} + /** - * Interactive ASCII wordmark: glyphs shove away from the cursor, then - * click-cycle through scatter, gravity drop, and reassemble. Zero deps. + * Interactive ASCII wordmark: glyphs assemble on load, shove away from the + * cursor, then click-cycle through scatter, gravity drop, and reassemble. + * Transparent, so it sits on any surface in light and dark. Zero deps. */ export function createAsciiLogo( root: HTMLElement, @@ -175,7 +262,9 @@ export function createAsciiLogo( | "cellSize" | "cellGap" | "charset" + | "edgeCharset" | "threshold" + | "assemble" | "hoverRadius" | "hoverPush" | "hoverEase" @@ -195,7 +284,9 @@ export function createAsciiLogo( cellSize: 11, cellGap: 2, charset: DEFAULT_CHARSET, + edgeCharset: EDGE_CHARSET, threshold: 0.2, + assemble: true, hoverRadius: 7, hoverPush: 2.6, hoverEase: 0.18, @@ -217,15 +308,20 @@ export function createAsciiLogo( let raf = 0 let running = true let last = performance.now() + let gridCols = 0 let gridRows = 0 let cells: AsciiCell[] = [] let phase: AsciiLogoPhase = "logo" let lastKey = "" + // The assemble flight plays when the source changes, not on a resize. + let lastSource: string | null = null let lastW = -1 let lastH = -1 let lastDpr = -1 let loadId = 0 let reduce = false + let dark = resolveDark(options.theme) + let fontFamily = "" const cursor = { x: -999, y: -999 } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") @@ -235,6 +331,20 @@ export function createAsciiLogo( onReduce() mqReduce.addEventListener("change", onReduce) + const syncTheme = () => { + dark = resolveDark(options.theme) + } + const unwatchTheme = watchTheme(syncTheme) + + const readFont = () => { + if (options.font) { + fontFamily = options.font + return + } + const computed = getComputedStyle(root).fontFamily + fontFamily = computed && computed !== "" ? computed : MONO_FALLBACK + } + const setPhase = (next: AsciiLogoPhase) => { if (phase === next) return phase = next @@ -258,24 +368,25 @@ export function createAsciiLogo( return { w, h } } + /** Paints the source onto a sampler `SUPERSAMPLE` times finer than the grid. */ const sampleSource = ( sampler: CanvasRenderingContext2D, - cols: number, - rows: number, + sw: number, + sh: number, image: HTMLImageElement | null, p: typeof options ) => { sampler.fillStyle = "#000" - sampler.fillRect(0, 0, cols, rows) + sampler.fillRect(0, 0, sw, sh) const cover = Math.min(1, Math.max(0.2, p.fit)) if (image && image.width > 0 && image.height > 0) { - const maxW = cols * cover - const maxH = rows * cover + const maxW = sw * cover + const maxH = sh * cover const scale = Math.min(maxW / image.width, maxH / image.height) const dw = image.width * scale const dh = image.height * scale - sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh) + sampler.drawImage(image, (sw - dw) / 2, (sh - dh) / 2, dw, dh) return } @@ -284,17 +395,25 @@ export function createAsciiLogo( sampler.fillStyle = "#fff" sampler.textAlign = "center" sampler.textBaseline = "middle" - let fontSize = rows * 0.52 + let fontSize = sh * 0.52 sampler.font = TEXT_FONT.replace("1px", `${fontSize}px`) const width = sampler.measureText(word).width - const maxW = cols * cover + const maxW = sw * cover if (width > maxW && width > 0) { fontSize *= maxW / width sampler.font = TEXT_FONT.replace("1px", `${fontSize}px`) } - sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04) + const x = sw / 2 + const y = sh / 2 + fontSize * 0.04 + sampler.fillText(word, x, y) + // Fatten the strokes by about a cell so thin counters still read. + sampler.strokeStyle = "#fff" + sampler.lineJoin = "round" + sampler.lineWidth = SUPERSAMPLE * 0.9 + sampler.strokeText(word, x, y) } + /** Averages each cell's block of sampler pixels into a coverage weight. */ const buildFromImageData = ( data: ImageData, cols: number, @@ -302,40 +421,47 @@ export function createAsciiLogo( p: typeof options ) => { const shouldInvert = p.invert ?? Boolean(p.src) - const lit = new Set() const pixels = data.data + const sw = cols * SUPERSAMPLE + const next: AsciiCell[] = [] + const block = SUPERSAMPLE * SUPERSAMPLE + for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { - const i = (row * cols + col) * 4 - const r = pixels[i] ?? 0 - const g = pixels[i + 1] ?? 0 - const b = pixels[i + 2] ?? 0 - const a = (pixels[i + 3] ?? 0) / 255 - const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255 - const value = (shouldInvert ? 1 - luma : luma) * a - if (value < p.threshold) continue - lit.add(`${col},${row}`) - if (col + 1 < cols) lit.add(`${col + 1},${row}`) + let sum = 0 + for (let sy = 0; sy < SUPERSAMPLE; sy++) { + const y = row * SUPERSAMPLE + sy + for (let sx = 0; sx < SUPERSAMPLE; sx++) { + const x = col * SUPERSAMPLE + sx + const i = (y * sw + x) * 4 + const r = pixels[i] ?? 0 + const g = pixels[i + 1] ?? 0 + const b = pixels[i + 2] ?? 0 + const a = (pixels[i + 3] ?? 0) / 255 + const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255 + sum += (shouldInvert ? 1 - luma : luma) * a + } + } + const weight = sum / block + if (weight < p.threshold) continue + next.push({ + col, + row, + char: charFor(weight, p.charset, p.edgeCharset), + weight, + offsetX: 0, + offsetY: 0, + scatterX: 0, + scatterY: 0, + fallSpeed: 0, + landing: 0, + spin: 0, + spinSpeed: 0, + flash: 0, + wait: 0, + }) } } - - const next: AsciiCell[] = [] - for (const key of lit) { - const [colStr, rowStr] = key.split(",") - const col = Number(colStr) - const row = Number(rowStr) - next.push({ - col, - row, - char: pickChar(p.charset), - offsetX: 0, - offsetY: 0, - scatterX: 0, - scatterY: 0, - fallSpeed: 0, - wait: 0, - }) - } return next } @@ -354,6 +480,7 @@ export function createAsciiLogo( p.cellSize, p.cellGap, p.charset, + p.edgeCharset, p.threshold, String(p.invert ?? ""), cols, @@ -383,29 +510,29 @@ export function createAsciiLogo( if (id !== loadId || !running) return } + const sw = cols * SUPERSAMPLE + const sh = rows * SUPERSAMPLE const sampler = document.createElement("canvas") - sampler.width = cols - sampler.height = rows + sampler.width = sw + sampler.height = sh const samplerCtx = sampler.getContext("2d", { willReadFrequently: true }) if (!samplerCtx) return const snapshot = { ...options, src: image ? options.src : undefined, } - sampleSource(samplerCtx, cols, rows, image, snapshot) + sampleSource(samplerCtx, sw, sh, image, snapshot) let data: ImageData | null = null try { - data = samplerCtx.getImageData(0, 0, cols, rows) + data = samplerCtx.getImageData(0, 0, sw, sh) } catch { data = null } if (!data && image) { - sampleSource(samplerCtx, cols, rows, null, { - ...snapshot, - src: undefined, - }) + // A tainted cross-origin image blocks reads; fall back to the text. + sampleSource(samplerCtx, sw, sh, null, { ...snapshot, src: undefined }) try { - data = samplerCtx.getImageData(0, 0, cols, rows) + data = samplerCtx.getImageData(0, 0, sw, sh) } catch { lastKey = "" return @@ -416,10 +543,24 @@ export function createAsciiLogo( return } cells = buildFromImageData(data, cols, rows, snapshot) + gridCols = cols gridRows = rows - setPhase("logo") cursor.x = -999 cursor.y = -999 + readFont() + + const source = `${snapshot.src ?? ""}|${snapshot.text}` + const fresh = source !== lastSource + lastSource = source + + if (fresh && p.assemble && p.interactive && !reduce && cells.length > 0) { + flingOffStage(cells, cols, rows) + staggerCells(cells, p.staggerFrames * 2) + phase = "logo" + setPhase("returning") + } else { + setPhase("logo") + } } const cyclePhase = () => { @@ -432,13 +573,29 @@ export function createAsciiLogo( cell.scatterX = (Math.random() * 2 - 1) * range cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72) cell.fallSpeed = 0 + cell.spinSpeed = (Math.random() * 2 - 1) * 0.05 } staggerCells(cells, p.staggerFrames) setPhase("scattered") return } if (phase === "scattered") { - for (const cell of cells) cell.fallSpeed = 0 + // Glyphs heap up column by column, lowest ones landing first, so the + // drop ends in drifts rather than one flat line. + const pile = new Array(Math.max(1, gridCols)).fill(0) + const order = [...cells].sort( + (a, b) => b.row + b.offsetY - (a.row + a.offsetY) + ) + for (const cell of order) { + const col = Math.min( + gridCols - 1, + Math.max(0, Math.round(cell.col + cell.offsetX)) + ) + const stack = pile[col]!++ + cell.landing = Math.max(-cell.row, gridRows - 1 - stack - cell.row) + cell.fallSpeed = 0 + cell.spinSpeed = (Math.random() * 2 - 1) * 0.22 + } setPhase("fallen") return } @@ -454,6 +611,8 @@ export function createAsciiLogo( let everyoneHome = phase === "returning" for (const cell of cells) { + if (cell.flash > 0) cell.flash = Math.max(0, cell.flash - 0.06 * frames) + if (cell.wait > 0) { cell.wait -= frames if (phase === "returning") everyoneHome = false @@ -463,6 +622,7 @@ export function createAsciiLogo( if (reduced || !p.interactive) { cell.offsetX = 0 cell.offsetY = 0 + cell.spin = 0 continue } @@ -473,24 +633,40 @@ export function createAsciiLogo( cell.scatterY, frameEase(p.scatterEase, frames) ) + cell.spin += cell.spinSpeed * frames + cell.spinSpeed *= Math.pow(0.97, frames) continue } if (phase === "fallen") { - const floor = Math.max(0, gridRows - 1 - cell.row) + const floor = cell.landing cell.fallSpeed += p.gravity * frames cell.offsetY += cell.fallSpeed * frames + cell.spin += cell.spinSpeed * frames if (cell.offsetY >= floor) { cell.offsetY = floor cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce)) if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0 + // Each landing bleeds off most of the tumble; glyphs come to rest + // on a flat side rather than balancing on a corner. + cell.spinSpeed *= 0.35 + if (cell.fallSpeed === 0) { + cell.spinSpeed = 0 + const rest = Math.round(cell.spin / (Math.PI / 2)) * (Math.PI / 2) + cell.spin += (rest - cell.spin) * frameEase(0.3, frames) + } } continue } if (phase === "returning") { easeToward(cell, 0, 0, frameEase(p.resetEase, frames)) - if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) { + cell.spin += (0 - cell.spin) * frameEase(p.resetEase, frames) + if ( + Math.abs(cell.offsetX) > 0.04 || + Math.abs(cell.offsetY) > 0.04 || + Math.abs(cell.spin) > 0.01 + ) { everyoneHome = false } continue @@ -513,23 +689,30 @@ export function createAsciiLogo( ) } if (Math.random() < 0.06 * frames) { - cell.char = pickChar(p.charset) + cell.char = charFor(cell.weight, p.charset, p.edgeCharset) + cell.flash = 1 } } else { easeToward(cell, 0, 0, frameEase(p.hoverEase, frames)) } } - if (everyoneHome) setPhase("logo") + if (everyoneHome) { + for (const cell of cells) { + cell.offsetX = 0 + cell.offsetY = 0 + cell.spin = 0 + } + setPhase("logo") + } } const draw = () => { const p = options const w = root.clientWidth const h = root.clientHeight - const dark = resolveDark(p.theme) - const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink) - const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper) + const ink = p.color ?? (dark ? DARK_INK : LIGHT_INK) + const paper = p.backgroundColor ?? "transparent" const step = Math.max(4, p.cellSize + p.cellGap) if (paper === "transparent") { @@ -539,7 +722,7 @@ export function createAsciiLogo( ctx.fillRect(0, 0, w, h) } - ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` + ctx.font = `${Math.max(6, p.cellSize)}px ${fontFamily || MONO_FALLBACK}` ctx.textAlign = "center" ctx.textBaseline = "middle" ctx.fillStyle = ink @@ -547,8 +730,21 @@ export function createAsciiLogo( for (const cell of cells) { const x = (cell.col + cell.offsetX) * step + step * 0.5 const y = (cell.row + cell.offsetY) * step + step * 0.5 - ctx.fillText(cell.char, x, y) + // Full ink inside the shape, lighter toward its rim, and a brief + // brightening on a swapped glyph. + const base = 0.4 + 0.6 * Math.min(1, cell.weight / 0.85) + ctx.globalAlpha = Math.min(1, base + cell.flash * 0.6) + if (Math.abs(cell.spin) > 0.001) { + ctx.save() + ctx.translate(x, y) + ctx.rotate(cell.spin) + ctx.fillText(cell.char, 0, 0) + ctx.restore() + } else { + ctx.fillText(cell.char, x, y) + } } + ctx.globalAlpha = 1 } const tick = (now: number) => { @@ -599,14 +795,19 @@ export function createAsciiLogo( cyclePhase() } + readFont() void rebuild() raf = requestAnimationFrame(tick) + // The observer also fires once right after `observe`; only a real size + // change rebuilds, so it doesn't cut the first assemble flight short. const ro = new ResizeObserver(() => { - lastW = -1 - lastH = -1 + if (root.clientWidth === lastW && root.clientHeight === lastH) return lastKey = "" void rebuild() + // Resizing cleared the canvas; draw now, or it shows blank for a frame + // until the next tick. + if (running) draw() }) ro.observe(root) @@ -621,6 +822,8 @@ export function createAsciiLogo( if (typeof next.text === "string") { options.text = clampAsciiLogoText(next.text) } + dark = resolveDark(options.theme) + if ("font" in next) readFont() }, destroy() { running = false @@ -628,6 +831,7 @@ export function createAsciiLogo( cancelAnimationFrame(raf) ro.disconnect() mqReduce.removeEventListener("change", onReduce) + unwatchTheme() window.removeEventListener("pointermove", onPointerMove) root.removeEventListener("pointerleave", onPointerLeave) root.removeEventListener("pointerdown", onPointerDown) diff --git a/registry/ascii-logo/ascii-logo.svelte b/registry/ascii-logo/ascii-logo.svelte index d970b91..c7b89ae 100644 --- a/registry/ascii-logo/ascii-logo.svelte +++ b/registry/ascii-logo/ascii-logo.svelte @@ -6,6 +6,7 @@ import { createAsciiLogo, DEFAULT_CHARSET, + EDGE_CHARSET, clampAsciiLogoText, type AsciiLogoInstance, type AsciiLogoOptions, @@ -29,10 +30,13 @@ cellSize = 11, cellGap = 2, charset = DEFAULT_CHARSET, + edgeCharset = EDGE_CHARSET, threshold = 0.2, invert, color, backgroundColor, + font, + assemble = true, hoverRadius = 7, hoverPush = 2.6, hoverEase = 0.18, @@ -61,10 +65,13 @@ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -92,10 +99,13 @@ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -121,7 +131,7 @@ aria-label={aria} tabindex={interactive ? 0 : undefined} class={cn( - "relative size-full overflow-hidden", + "relative size-full overflow-hidden font-mono", interactive && "cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none", className diff --git a/registry/ascii-logo/ascii-logo.tsx b/registry/ascii-logo/ascii-logo.tsx index 52c32f4..6586b3a 100644 --- a/registry/ascii-logo/ascii-logo.tsx +++ b/registry/ascii-logo/ascii-logo.tsx @@ -7,13 +7,17 @@ import { cn } from "@/lib/utils" import { createAsciiLogo, DEFAULT_CHARSET, + EDGE_CHARSET, clampAsciiLogoText, type AsciiLogoInstance, type AsciiLogoOptions, } from "./ascii-logo-vanilla" export { + DARK_INK, DEFAULT_CHARSET, + EDGE_CHARSET, + LIGHT_INK, MAX_TEXT_LETTERS, clampAsciiLogoText, } from "./ascii-logo-vanilla" @@ -31,8 +35,9 @@ export type AsciiLogoProps = AsciiLogoOptions & { } /** - * Interactive ASCII wordmark: glyphs shove away from the cursor, then - * click-cycle through scatter, gravity drop, and reassemble. Zero deps. + * Interactive ASCII wordmark: glyphs assemble on load, shove away from the + * cursor, then click-cycle through scatter, gravity drop, and reassemble. + * Transparent, so it sits on any surface in light and dark. Zero deps. */ export function AsciiLogo({ className, @@ -42,10 +47,13 @@ export function AsciiLogo({ cellSize = 11, cellGap = 2, charset = DEFAULT_CHARSET, + edgeCharset = EDGE_CHARSET, threshold = 0.2, invert, color, backgroundColor, + font, + assemble = true, hoverRadius = 7, hoverPush = 2.6, hoverEase = 0.18, @@ -75,10 +83,13 @@ export function AsciiLogo({ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -108,10 +119,13 @@ export function AsciiLogo({ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -132,10 +146,13 @@ export function AsciiLogo({ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -160,7 +177,7 @@ export function AsciiLogo({ aria-label={aria} tabIndex={interactive ? 0 : undefined} className={cn( - "relative size-full overflow-hidden", + "relative size-full overflow-hidden font-mono", interactive && "cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none", className diff --git a/registry/dithered-404/dithered-404-demo.tsx b/registry/dithered-404/dithered-404-demo.tsx index 70e680a..4e4e5c2 100644 --- a/registry/dithered-404/dithered-404-demo.tsx +++ b/registry/dithered-404/dithered-404-demo.tsx @@ -59,7 +59,7 @@ export function Dithered404Demo() { title="Lost page" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    { dpr = Math.min(window.devicePixelRatio || 1, 2) const w = wrap.clientWidth const h = wrap.clientHeight - if (w <= 0 || h <= 0) return + if (w <= 0 || h <= 0) return false cssW = w cssH = h - canvas.width = Math.max(1, Math.floor(w * dpr)) - canvas.height = Math.max(1, Math.floor(h * dpr)) + const width = Math.max(1, Math.floor(w * dpr)) + const height = Math.max(1, Math.floor(h * dpr)) + const changed = width !== canvas.width || height !== canvas.height + if (changed) { + canvas.width = width + canvas.height = height + } canvas.style.width = `${w}px` canvas.style.height = `${h}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) @@ -602,10 +608,18 @@ export function createDithered404( lastH = h rebuild(false) } + return changed } resize() - const ro = new ResizeObserver(resize) + // Resizing cleared the canvas; draw now (`tick` is declared below, and the + // observer only fires after setup), or it shows blank for a frame. + const ro = new ResizeObserver(() => { + if (!resize() || !running) return + cancelAnimationFrame(raf) + lastNow = performance.now() + tick(lastNow) + }) ro.observe(wrap) const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") diff --git a/registry/folio/folio-demo.tsx b/registry/folio/folio-demo.tsx index 97ae00f..f23083e 100644 --- a/registry/folio/folio-demo.tsx +++ b/registry/folio/folio-demo.tsx @@ -206,7 +206,7 @@ export function FolioDemo() { title="Folio" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    diff --git a/registry/folio/folio.tsx b/registry/folio/folio.tsx index 554ece2..3eeb5b9 100644 --- a/registry/folio/folio.tsx +++ b/registry/folio/folio.tsx @@ -91,7 +91,7 @@ export function Folio({ data-slot="folio" data-folio-demo={demoId} className={cn( - "relative h-full overflow-x-hidden overflow-y-auto overscroll-contain", + "relative h-full overflow-x-hidden overflow-y-auto", className )} > diff --git a/registry/gooey-color-picker/gooey-color-picker.svelte b/registry/gooey-color-picker/gooey-color-picker.svelte index 4534d64..7ea5a11 100644 --- a/registry/gooey-color-picker/gooey-color-picker.svelte +++ b/registry/gooey-color-picker/gooey-color-picker.svelte @@ -234,7 +234,7 @@
    updateWheelFromPointer(x, y))} @@ -275,8 +275,8 @@ aria-valuemax={100} aria-valuenow={Math.round(color.a * 100)} class={cn( - "relative mt-3 h-9 w-full shrink-0 touch-none overflow-hidden rounded-xl outline-none ring-1 ring-white/10", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-black" + "relative mt-3 h-9 w-full shrink-0 touch-none overflow-hidden rounded-xl outline-none ring-1 ring-foreground/10", + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover" )} style="background: linear-gradient(to right, transparent, {opaque});" onpointerdown={bindDrag((x) => updateAlphaFromPointer(x))} @@ -289,10 +289,10 @@
    @@ -310,7 +310,7 @@ commitHex(hexDraft) }} onkeydown={onHexKeyDown} - class="min-w-0 flex-1 bg-transparent font-mono text-sm text-white outline-none placeholder:text-white/40" + class="min-w-0 flex-1 bg-transparent font-mono text-sm text-popover-foreground outline-none placeholder:text-muted-foreground" /> {#if supportsEyeDropper}
    -
    +
    @@ -470,7 +470,7 @@ export function GooeyColorPicker({ event.currentTarget.blur() } }} - className="min-w-0 flex-1 bg-transparent font-mono text-sm text-white outline-none placeholder:text-white/40" + className="min-w-0 flex-1 bg-transparent font-mono text-sm text-popover-foreground outline-none placeholder:text-muted-foreground" /> {supportsEyeDropper ? ( + ))} +
    +
    + setRing("rotation", v)} + /> + setRing("tilt", v)} + /> + `${v}°/s`} + onChange={(v) => setRing("spin", v)} + /> + setRing("speed", v)} + /> + setRing("parallax", v)} + /> void } -export const DEFAULT_RINGS = 12 +export const DEFAULT_RINGS = 5 +export const MAX_RINGS = 5 export const DEFAULT_SPEED = 1 export const DEFAULT_PARALLAX = 24 export const DEFAULT_BACKFACE = 0.86 +export const DEFAULT_TILT = 14 +export const DEFAULT_SPIN = 0 const RADIUS = 4 const BAND_HEIGHT = 2 const SPACING = 3.5 -const TILT = 0.25 const SEGMENTS = 100 const STRIP_HEIGHT = 512 const CRAWL = 0.012 const START_ANGLE = 0.5 +const DEG = Math.PI / 180 /** Lean cycle per ring; signs alternate so neighbors counter-turn. */ const PARALLAX_FACTORS = [1, -0.75, 1.25, -0.9] @@ -76,6 +111,41 @@ function numberOr(value: unknown, fallback: number, min: number, max: number) { return clamp(value, min, max) } +/** Default rotation for ring `i`: a quarter turn from the one below. */ +export function defaultRotation(i: number) { + return (i * 90) % 360 +} + +/** Default parallax for ring `i`: neighbors turn opposite ways. */ +export function defaultParallax(i: number, parallax = DEFAULT_PARALLAX) { + return parallax * PARALLAX_FACTORS[i % PARALLAX_FACTORS.length]! +} + +/** The ring count and each ring's own settings (empty where unset). */ +function ringsOf(value: RingTowerOptions["rings"]): RingTowerRing[] { + if (Array.isArray(value) && value.length > 0) { + return value + .slice(0, MAX_RINGS) + .map((ring) => (ring && typeof ring === "object" ? ring : {})) + } + const count = Math.round( + numberOr( + Array.isArray(value) ? undefined : value, + DEFAULT_RINGS, + 1, + MAX_RINGS + ) + ) + return Array.from({ length: count }, () => ({})) +} + +function urlsOf(images: unknown): string[] { + if (!Array.isArray(images)) return [] + return images.filter( + (url): url is string => typeof url === "string" && url.length > 0 + ) +} + /** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ export function isDarkTheme(): boolean { if (typeof document === "undefined") return false @@ -219,11 +289,22 @@ export function createRingTower( let darkMix = dark ? 1 : 0 const backColor = { value: new Color().setScalar(1 - darkMix) } - type Band = { mesh: Mesh; material: MeshBasicMaterial; texture: Texture } + type Band = { + index: number + mesh: Mesh + material: MeshBasicMaterial + texture: Texture + tiles: number + } let bands: Band[] = [] - let strip: Strip | null = null - let base: CanvasTexture | null = null - let tiles = 1 + // One strip per distinct image list, shared by every ring that shows it. + // `base` is null while the images load. + type Entry = { base: CanvasTexture | null; tiles: number; token: number } + const strips = new Map() + let rings = ringsOf(options.rings) + // Kept across rebuilds, so a strip arriving never snaps a ring back. + const crawls: number[] = [] + const turns: number[] = [] const makeMaterial = (map: Texture) => { const material = new MeshBasicMaterial({ @@ -255,51 +336,51 @@ export function createRingTower( for (const band of bands) { tower.remove(band.mesh) band.material.dispose() - if (band.texture !== base) band.texture.dispose() + band.texture.dispose() } bands = [] } // Whole tiles around the band keep the seam invisible; the strip is then - // cropped top and bottom to hold its proportions. + // cropped top and bottom to hold its proportions. Returns the tile count. const fit = (texture: Texture, aspect: number) => { const around = (2 * Math.PI * RADIUS) / BAND_HEIGHT const whole = Math.floor(around / aspect) if (whole >= 1) { - tiles = whole texture.repeat.set(whole, Math.min(1, (aspect * whole) / around)) texture.offset.y = (1 - texture.repeat.y) / 2 - } else { - tiles = around / aspect - texture.repeat.set(tiles, 1) - texture.offset.y = 0 + return whole } + texture.repeat.set(around / aspect, 1) + texture.offset.y = 0 + return around / aspect } + const imagesFor = (ring: RingTowerRing) => + urlsOf(ring.images).length ? urlsOf(ring.images) : urlsOf(options.images) + const keyFor = (ring: RingTowerRing) => imagesFor(ring).join("\n") + const buildBands = () => { clearBands() - if (!base) return - const count = Math.round(numberOr(options.rings, DEFAULT_RINGS, 1, 24)) - const middle = Math.ceil(count / 2) - 1 - for (let i = 0; i < count; i++) { - // Clones share the image source, so the strip uploads once. - const texture = i === 0 ? base : base.clone() + const middle = Math.ceil(rings.length / 2) - 1 + rings.forEach((ring, i) => { + const entry = strips.get(keyFor(ring)) + if (!entry?.base) return + // Clones share the image source, so each strip uploads once. + const texture = entry.base.clone() const material = makeMaterial(texture) const mesh = new Mesh(geometry, material) - mesh.rotation.set(0, i * Math.PI * 0.5, TILT) mesh.position.set(0, (i - middle) * SPACING, 0) tower.add(mesh) - bands.push({ mesh, material, texture }) - } + bands.push({ index: i, mesh, material, texture, tiles: entry.tiles }) + }) + pose() } - const setStrip = (next: Strip | null) => { - clearBands() - base?.dispose() - base = null - strip = next - if (!strip) return - const texture = new CanvasTexture(strip.canvas) + const setStrip = (key: string, next: Strip | null) => { + const entry = strips.get(key) + if (!entry || !next) return + const texture = new CanvasTexture(next.canvas) texture.wrapS = RepeatWrapping texture.wrapT = ClampToEdgeWrapping texture.generateMipmaps = false @@ -307,48 +388,86 @@ export function createRingTower( texture.magFilter = LinearFilter texture.colorSpace = SRGBColorSpace texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy()) - fit(texture, strip.aspect) - base = texture + entry.tiles = fit(texture, next.aspect) + entry.base = texture buildBands() wake() } let loadToken = 0 - let imagesKey = "" + let layoutKey = "" + // Loads any image list a ring now needs, drops the ones none show, and + // rebuilds the rings when their count or images change. const loadImages = () => { - const urls = (options.images ?? []).filter( - (url) => typeof url === "string" && url.length > 0 - ) - const key = urls.join("\n") - if (key === imagesKey && strip) return - imagesKey = key - const token = ++loadToken - if (urls.length === 0) { - setStrip(gridStrip()) - return + const needed = new Map(rings.map((ring) => [keyFor(ring), imagesFor(ring)])) + for (const [key, entry] of strips) { + if (needed.has(key)) continue + entry.base?.dispose() + strips.delete(key) + } + for (const [key, urls] of needed) { + if (strips.has(key)) continue + const token = ++loadToken + strips.set(key, { base: null, tiles: 1, token }) + if (urls.length === 0) { + setStrip(key, gridStrip()) + continue + } + void Promise.allSettled(urls.map(loadImage)).then((results) => { + if (destroyed || strips.get(key)?.token !== token) return + const loaded = results.flatMap((r) => + r.status === "fulfilled" ? [r.value] : [] + ) + const next = loaded.length + ? stitch(loaded, renderer.capabilities.maxTextureSize) + : null + setStrip(key, next ?? gridStrip()) + }) + } + const nextLayout = rings.map(keyFor).join("\0") + if (nextLayout !== layoutKey) { + layoutKey = nextLayout + buildBands() } - void Promise.allSettled(urls.map(loadImage)).then((results) => { - if (token !== loadToken || destroyed) return - const loaded = results.flatMap((r) => - r.status === "fulfilled" ? [r.value] : [] + } + + // Each ring's lean, heading and spin, read live so sliders need no rebuild. + const pose = () => { + for (const band of bands) { + const ring = rings[band.index] ?? {} + const rotation = numberOr( + ring.rotation, + defaultRotation(band.index), + -3600, + 3600 ) - const next = loaded.length - ? stitch(loaded, renderer.capabilities.maxTextureSize) - : null - setStrip(next ?? gridStrip()) - }) + const tilt = numberOr(ring.tilt, DEFAULT_TILT, -45, 45) + band.mesh.rotation.set( + 0, + rotation * DEG + (turns[band.index] ?? 0), + tilt * DEG + ) + } } const size = { width: 0, height: 0 } + const current = new Vector2() + /** + * Sizes the renderer to the root; true when the canvas changed. Setting + * the same size still clears the canvas, so it only sets a real change. + */ const resize = () => { const { width, height } = root.getBoundingClientRect() size.width = width size.height = height - if (width <= 0 || height <= 0) return + if (width <= 0 || height <= 0) return false + renderer.getSize(current) + if (current.x === width && current.y === height) return false renderer.setSize(width, height) camera.aspect = width / height camera.updateProjectionMatrix() wake() + return true } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") @@ -357,7 +476,6 @@ export function createRingTower( let destroyed = false let lost = false let last = 0 - let crawl = 0 let angle = START_ANGLE let velocity = 0 let dragging = false @@ -381,11 +499,25 @@ export function createRingTower( if (!visible()) return const still = mqReduce.matches + const towerSpeed = numberOr(options.speed, DEFAULT_SPEED, 0, 4) + const towerParallax = numberOr(options.parallax, DEFAULT_PARALLAX, 0, 90) + let spinning = false if (!still) { - // In texture units, so wrapping at 1 never shows a jump. - crawl += dt * CRAWL * tiles * numberOr(options.speed, DEFAULT_SPEED, 0, 4) - crawl %= 1 + for (const band of bands) { + const ring = rings[band.index] ?? {} + const i = band.index + const speed = numberOr(ring.speed, towerSpeed, -4, 4) + // In texture units, so wrapping at 1 never shows a jump. + const crawl = (crawls[i] ?? 0) + dt * CRAWL * band.tiles * speed + crawls[i] = crawl - Math.floor(crawl) + const spin = numberOr(ring.spin, DEFAULT_SPIN, -180, 180) + if (spin !== 0) { + turns[i] = ((turns[i] ?? 0) + spin * DEG * dt) % (Math.PI * 2) + spinning = true + } + } } + pose() if (!dragging) { if (still) velocity = 0 angle += velocity * dt @@ -397,12 +529,17 @@ export function createRingTower( const ease = 1 - Math.exp(-dt * 4) const target = still ? 0 : pointerTarget pointerX += (target - pointerX) * ease - const reach = - (numberOr(options.parallax, DEFAULT_PARALLAX, 0, 90) / 360) * tiles - bands.forEach((band, i) => { - const factor = PARALLAX_FACTORS[i % PARALLAX_FACTORS.length]! - band.texture.offset.x = crawl + pointerX * reach * factor - }) + for (const band of bands) { + const ring = rings[band.index] ?? {} + const parallax = numberOr( + ring.parallax, + defaultParallax(band.index, towerParallax), + -90, + 90 + ) + band.texture.offset.x = + (crawls[band.index] ?? 0) + (pointerX * parallax * band.tiles) / 360 + } darkMix += ((dark ? 1 : 0) - darkMix) * (1 - Math.exp(-dt * 6)) if (Math.abs((dark ? 1 : 0) - darkMix) < 1e-3) darkMix = dark ? 1 : 0 @@ -411,7 +548,8 @@ export function createRingTower( if (size.width > 0 && size.height > 0) renderer.render(scene, camera) const moving = - !still || + (!still && + (towerSpeed !== 0 || spinning || rings.some((r) => r.speed))) || dragging || velocity !== 0 || Math.abs(target - pointerX) > 1e-4 || @@ -485,7 +623,11 @@ export function createRingTower( canvas.addEventListener("pointercancel", onUp) canvas.addEventListener("pointerleave", onLeave) - const ro = new ResizeObserver(resize) + // Resizing cleared the canvas; draw the current pose now, or it shows + // blank for a frame. + const ro = new ResizeObserver(() => { + if (resize() && !destroyed && !lost) renderer.render(scene, camera) + }) ro.observe(root) const io = new IntersectionObserver(([entry]) => { onScreen = entry?.isIntersecting ?? true @@ -506,7 +648,7 @@ export function createRingTower( const onRestored = () => { if (destroyed) return lost = false - if (base) base.needsUpdate = true + for (const { base } of strips.values()) if (base) base.needsUpdate = true wake() } canvas.addEventListener("webglcontextlost", onLost) @@ -537,14 +679,13 @@ export function createRingTower( return { setOptions(next) { - const prevRings = options.rings options = { ...options, ...next } + rings = ringsOf(options.rings) applyBackface() if (options.draggable === false) { dragging = false setCursor("") } - if (options.rings !== prevRings) buildBands() loadImages() syncTheme() wake() @@ -568,7 +709,8 @@ export function createRingTower( canvas.removeEventListener("webglcontextlost", onLost) canvas.removeEventListener("webglcontextrestored", onRestored) clearBands() - base?.dispose() + for (const { base } of strips.values()) base?.dispose() + strips.clear() geometry.dispose() renderer.dispose() renderer.forceContextLoss() diff --git a/registry/ring-tower/ring-tower.tsx b/registry/ring-tower/ring-tower.tsx index edc8d2a..19a258c 100644 --- a/registry/ring-tower/ring-tower.tsx +++ b/registry/ring-tower/ring-tower.tsx @@ -19,10 +19,16 @@ export { DEFAULT_PARALLAX, DEFAULT_RINGS, DEFAULT_SPEED, + DEFAULT_SPIN, + DEFAULT_TILT, + MAX_RINGS, + defaultParallax, + defaultRotation, } from "./ring-tower-vanilla" export type { RingTowerInstance, RingTowerOptions, + RingTowerRing, RingTowerTheme, } from "./ring-tower-vanilla" @@ -33,8 +39,9 @@ export type RingTowerProps = RingTowerOptions & { /** * A tower of tilted bands wrapped in your images. The images crawl around * the bands, dragging spins the tower, and the pointer turns neighboring - * bands opposite ways. Transparent; the parent paints the background. - * Theme-aware. + * bands opposite ways. Pass `rings` an array to set each ring's rotation, + * tilt, spin, speed, parallax or images. Transparent; the parent paints the + * background. Theme-aware. */ export function RingTower({ className, diff --git a/registry/shader-anime-fire/shader-anime-fire-demo.tsx b/registry/shader-anime-fire/shader-anime-fire-demo.tsx index 03f3670..530ffcf 100644 --- a/registry/shader-anime-fire/shader-anime-fire-demo.tsx +++ b/registry/shader-anime-fire/shader-anime-fire-demo.tsx @@ -70,7 +70,7 @@ export function ShaderAnimeFireDemo() { title="Landing hero" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    { const parent = canvas.parentElement - if (!parent) return + if (!parent) return false const w = parent.clientWidth const h = parent.clientHeight - if (w <= 0 || h <= 0) return + if (w <= 0 || h <= 0) return false dpr = Math.min(window.devicePixelRatio || 1, 1.5) - canvas.width = Math.max(1, Math.floor(w * dpr)) - canvas.height = Math.max(1, Math.floor(h * dpr)) + const width = Math.max(1, Math.floor(w * dpr)) + const height = Math.max(1, Math.floor(h * dpr)) + if (canvas.width === width && canvas.height === height) return false + canvas.width = width + canvas.height = height canvas.style.width = `${w}px` canvas.style.height = `${h}px` if (!lost) gl.viewport(0, 0, canvas.width, canvas.height) wake() + return true } let colorsKey = "" @@ -459,6 +468,15 @@ export function createShaderAnimeFire( raf = requestAnimationFrame(tick) } + /** Draws in place now, without advancing; the tick carries on if live. */ + function redraw() { + if (destroyed || lost) return + cancelAnimationFrame(raf) + raf = 0 + last = performance.now() + tick(last) + } + // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside. const tick = (now: number) => { raf = 0 @@ -505,7 +523,10 @@ export function createShaderAnimeFire( } } - const ro = new ResizeObserver(resize) + // Resizing cleared the canvas; draw now, or it shows blank for a frame. + const ro = new ResizeObserver(() => { + if (resize()) redraw() + }) if (canvas.parentElement) ro.observe(canvas.parentElement) const io = new IntersectionObserver(([entry]) => { onScreen = entry?.isIntersecting ?? true diff --git a/registry/shader-gradient/shader-gradient-demo.tsx b/registry/shader-gradient/shader-gradient-demo.tsx index 6b82960..64d8bd7 100644 --- a/registry/shader-gradient/shader-gradient-demo.tsx +++ b/registry/shader-gradient/shader-gradient-demo.tsx @@ -71,7 +71,7 @@ export function ShaderGradientDemo() { title="Landing hero" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    { const parent = canvas.parentElement - if (!parent) return + if (!parent) return false const w = parent.clientWidth const h = parent.clientHeight - if (w <= 0 || h <= 0) return + if (w <= 0 || h <= 0) return false pixel = Math.min(window.devicePixelRatio || 1, 1) - canvas.width = Math.max(1, Math.floor(w * pixel)) - canvas.height = Math.max(1, Math.floor(h * pixel)) + const width = Math.max(1, Math.floor(w * pixel)) + const height = Math.max(1, Math.floor(h * pixel)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` - gl.viewport(0, 0, canvas.width, canvas.height) wake() + // Assigning a size clears the canvas, even the same size. + if (width === canvas.width && height === canvas.height) return false + canvas.width = width + canvas.height = height + gl.viewport(0, 0, canvas.width, canvas.height) + return true } let colorsKey = "" @@ -475,6 +481,18 @@ export function createShaderGradient( if (live) raf = requestAnimationFrame(tick) } + /** + * Draws now: resizing cleared the canvas, and waiting for the next frame + * would show it blank for one. + */ + const redraw = () => { + if (destroyed || lost) return + cancelAnimationFrame(raf) + raf = 0 + last = performance.now() + tick(last) + } + // A GPU reset, a driver update or too many live contexts can drop ours. // preventDefault asks the browser to restore it; until then gl is off limits. const onLost = (e: Event) => { @@ -494,7 +512,9 @@ export function createShaderGradient( canvas.addEventListener("webglcontextlost", onLost) canvas.addEventListener("webglcontextrestored", onRestored) - const ro = new ResizeObserver(resize) + const ro = new ResizeObserver(() => { + if (resize()) redraw() + }) if (canvas.parentElement) ro.observe(canvas.parentElement) const io = new IntersectionObserver(([entry]) => { onScreen = entry?.isIntersecting ?? true diff --git a/registry/shader-metal/shader-metal-demo.tsx b/registry/shader-metal/shader-metal-demo.tsx index 784dc78..bd1efa1 100644 --- a/registry/shader-metal/shader-metal-demo.tsx +++ b/registry/shader-metal/shader-metal-demo.tsx @@ -48,7 +48,7 @@ export function ShaderMetalDemo() { title="Landing hero" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    { const parent = canvas.parentElement - if (!parent) return + if (!parent) return false const w = parent.clientWidth const h = parent.clientHeight - if (w <= 0 || h <= 0) return + if (w <= 0 || h <= 0) return false const ratio = Math.min( window.devicePixelRatio || 1, 2, Math.sqrt(MAX_PIXELS / (w * h)) ) - canvas.width = Math.max(1, Math.floor(w * ratio)) - canvas.height = Math.max(1, Math.floor(h * ratio)) + const width = Math.max(1, Math.floor(w * ratio)) + const height = Math.max(1, Math.floor(h * ratio)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` - gl.viewport(0, 0, canvas.width, canvas.height) wake() + // Assigning a size clears the canvas, even the same size. + if (width === canvas.width && height === canvas.height) return false + canvas.width = width + canvas.height = height + gl.viewport(0, 0, canvas.width, canvas.height) + return true } let f0Key = "" @@ -533,7 +539,21 @@ export function createShaderMetal( if (flowing || settling()) raf = requestAnimationFrame(tick) } - const ro = new ResizeObserver(resize) + /** + * Draws now: resizing cleared the canvas, and waiting for the next frame + * would show it blank for one. + */ + const redraw = () => { + if (destroyed || lost) return + cancelAnimationFrame(raf) + raf = 0 + last = performance.now() + tick(last) + } + + const ro = new ResizeObserver(() => { + if (resize()) redraw() + }) if (canvas.parentElement) ro.observe(canvas.parentElement) const io = new IntersectionObserver(([entry]) => { onScreen = entry?.isIntersecting ?? true diff --git a/registry/shader-sky/shader-sky-demo.tsx b/registry/shader-sky/shader-sky-demo.tsx index 00339d8..8b690b5 100644 --- a/registry/shader-sky/shader-sky-demo.tsx +++ b/registry/shader-sky/shader-sky-demo.tsx @@ -39,7 +39,7 @@ export function ShaderSkyDemo() { title="Landing hero" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    { const parent = canvas.parentElement - if (!parent) return + if (!parent) return false const w = parent.clientWidth const h = parent.clientHeight - if (w <= 0 || h <= 0) return + if (w <= 0 || h <= 0) return false const dpr = Math.min(window.devicePixelRatio || 1, 2) pixelScale = Math.min(dpr, Math.sqrt(MAX_PIXELS / (w * h))) - canvas.width = Math.max(1, Math.floor(w * pixelScale)) - canvas.height = Math.max(1, Math.floor(h * pixelScale)) + const width = Math.max(1, Math.floor(w * pixelScale)) + const height = Math.max(1, Math.floor(h * pixelScale)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` - if (!lost) gl.viewport(0, 0, canvas.width, canvas.height) wake() + // Assigning a size clears the canvas, even the same size. + if (width === canvas.width && height === canvas.height) return false + canvas.width = width + canvas.height = height + if (!lost) gl.viewport(0, 0, canvas.width, canvas.height) + return true } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") @@ -703,9 +709,23 @@ export function createShaderSky( if (live || (easing && shown())) raf = requestAnimationFrame(tick) } + /** + * Draws now: resizing cleared the canvas, and waiting for the next frame + * would show it blank for one. + */ + const redraw = () => { + if (destroyed || lost) return + cancelAnimationFrame(raf) + raf = 0 + last = performance.now() + tick(last) + } + // Run after `tick` exists, since both can wake the loop. resize() - const ro = new ResizeObserver(resize) + const ro = new ResizeObserver(() => { + if (resize()) redraw() + }) if (canvas.parentElement) ro.observe(canvas.parentElement) onReduce() const io = new IntersectionObserver(([entry]) => { diff --git a/registry/stretchy-footer/stretchy-footer-demo.tsx b/registry/stretchy-footer/stretchy-footer-demo.tsx index b08f6fd..0c495bb 100644 --- a/registry/stretchy-footer/stretchy-footer-demo.tsx +++ b/registry/stretchy-footer/stretchy-footer-demo.tsx @@ -113,7 +113,7 @@ export function StretchyFooterDemo() { title="Stretchy Footer" stageClassName="min-h-0 overflow-hidden p-0" > -
    +
    -
    +
    |Screenshots on a spinning tower| rt[ring-tower] atmo -->|Hyperspace streaks, scroll warp| rl[radiant-lines] atmo -->|Hair-line explosion| lb[logo-burst] + atmo -->|Isometric figures built from tiles| il[iso-lattice] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] mark -->|Eyes that track the pointer| orb[live-orb] @@ -119,6 +120,7 @@ Disambiguation that agents get wrong: | Page leans while scrolling | `folio` | `stretchy-footer` (that one stretches, it does not tilt) | | Stars that speed up when you scroll | `radiant-lines` | `logo-burst` | | Logo explodes into lines | `logo-burst` | `ascii-logo` | +| Isometric cube, stairs, bar chart, pixel-tile figure | `iso-lattice` | `logo-burst` (lines, not a figure) | | ASCII letters that shove, scatter, and fall | `ascii-logo` | `ascii-fluid` (trails, not a wordmark) | | CRT notation, phosphor, staves | `phosphor-score` | `shader-sky` | | Chrome, liquid metal, gold ribbons | `shader-metal` | `shader-gradient` (a color wash, not a material) | @@ -139,7 +141,7 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). +Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, `IsoLattice`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `ShaderMetal` uses the same shell but is transparent: the ribbons and their shadows sit on the parent's background, so set one (`bg-background`). The ribbons cross the middle; put copy top-left. @@ -181,6 +183,7 @@ Categories match `content/docs/components/meta.json`. | `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | React + Svelte | | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | +| `iso-lattice` | Background | Isometric tiles build solid figures from the floor up | React + Svelte | | `ring-tower` | Background | three.js tower of tilted image bands; drag to spin, neighbors counter-turn | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | | `shader-anime-fire` | Shaders | Cel-shaded flames licking up from the bottom | React + Svelte | diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index ff35c1c..74273d2 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -68,6 +68,31 @@ Constants: `LIGHT_COLOR` `#3F3F46`, `DARK_COLOR` `#D6D2CA`. `DEFAULT_COLOR` is d Vanilla `LogoBurstInstance`: `{ setOptions, replay, destroy }`. Not returned to app code by the React component. +## iso-lattice + +```ts +type IsoLatticeTheme = "light" | "dark" | "auto" +type IsoLatticeShape = "cube" | "steps" | "pillars" | "coil" + +type IsoLatticeProps = { + shapes?: IsoLatticeShape[] // ["cube", "steps", "pillars", "coil"]; one item holds still + interval?: number // 4 seconds per figure + color?: string + size?: number // 0.6 of the shorter side, 0.2–1 + spacing?: number // 8 CSS px between tiles, 4–24 + glow?: number // 0.6, 0–1 + speed?: number // 1, glint and shimmer only + floor?: boolean // true + interactive?: boolean // true + theme?: IsoLatticeTheme // "auto" + className?: string +} +``` + +Constants: `LIGHT_COLOR` `#C2560C`, `DARK_COLOR` `#FFB25B`, `SHAPES`. Each build takes a fixed 1.6 s; it is not a prop. + +Vanilla `IsoLatticeInstance`: `{ setOptions, destroy }`. Not returned to app code by the React component. + ## phosphor-score ```ts @@ -92,7 +117,8 @@ type PhosphorScoreProps = { ```ts type RadiantLinesProps = { - colors?: string[] + colors?: string[] // omit → theme palette + theme?: "light" | "dark" | "auto" // "auto" starCount?: number // 420 displacement?: number // 1, values < 0 clamp to 0 containerRef?: RefObject // omit → window @@ -100,7 +126,7 @@ type RadiantLinesProps = { } ``` -`DEFAULT_COLORS`: `#FF6B4A`, `#2DD4BF`, `#FBBF24`, `#60A5FA`, `#F472B6`, `#A3E635`, `#94A3B8`. +`DEFAULT_COLORS` (dark pages): `#FF6B4A`, `#2DD4BF`, `#FBBF24`, `#60A5FA`, `#F472B6`, `#A3E635`, `#94A3B8`. `LIGHT_COLORS` (light pages): the same hues, deeper. A set `colors` wins in both themes. Instance: `{ setOptions, destroy }`. @@ -289,7 +315,8 @@ type StretchyFooterPlayDetail = { type LiveOrbVariant = "white" | "black" | "webgl" | "custom" type LiveOrbProps = { - variant?: LiveOrbVariant // "white" + variant?: LiveOrbVariant // omit → "white" on dark pages, "black" on light + theme?: "light" | "dark" | "auto" // "auto" color?: string // custom body, default #7C5CFF eyeColor?: string // custom eyes, default #FAFAFA colors?: string[] // webgl stops @@ -315,11 +342,14 @@ type AsciiLogoProps = { fit?: number // 0.82 cellSize?: number // 11 cellGap?: number // 2 - charset?: string // DEFAULT_CHARSET - threshold?: number // 0.2 + charset?: string // DEFAULT_CHARSET, cells inside the shape + edgeCharset?: string // EDGE_CHARSET, light marks on the rim + threshold?: number // 0.2, coverage a cell needs invert?: boolean // true if src set, else false - color?: string - backgroundColor?: string // "transparent" skips fill + color?: string // omit → LIGHT_INK / DARK_INK by theme + backgroundColor?: string // "transparent" + font?: string // omit → root's computed font; wrapper sets font-mono + assemble?: boolean // true, glyphs fly in on build hoverRadius?: number // 7 hoverPush?: number // 2.6 hoverEase?: number // 0.18 diff --git a/skills/23rd/components.md b/skills/23rd/components.md index a77ca94..1f16ee2 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -38,6 +38,34 @@ export function Mark() { Pitfalls: `color` overrides theme ink. Light filament is `#3F3F46`, dark is `#D6D2CA`. `DEFAULT_COLOR` is deprecated (it is the dark color). Reduced motion draws the settled field and skips the explosion. Click replays unless `replayOnClick={false}`. +## iso-lattice + +Background. A fixed lattice of isometric diamond tiles lights up solid figures (cube, steps, pillars, coil) on a faint floor grid, and builds each one over the last from the floor up. A level glint rises through the figure, the pointer lights the lattice like a lamp. Transparent canvas. + +Best fit: an infrastructure or developer-tool hero that wants one luminous object under the headline; a section divider with a figure that changes. + +Not this: hair-line explosion (`logo-burst`), scroll starfield (`radiant-lines`), images on a tower (`ring-tower`). + +Deps: none (WebGL). Key props: `shapes` (`["cube", "steps", "pillars", "coil"]`), `interval` (4s), `color`, `theme` (`auto`), `size` (0.6), `spacing` (8px), `glow` (0.6), `speed` (1), `floor` (true), `interactive` (true). + +```tsx +"use client" +import { IsoLattice } from "@/components/ui/iso-lattice" + +export function Hero() { + return ( +
    +

    Ship the build

    +
    + +
    +
    + ) +} +``` + +Pitfalls: the figure is centered in the canvas, so give the lattice its own box below the copy instead of centering copy over it. `color` overrides the theme amber; light is `#C2560C`, dark `#FFB25B`. Unknown names in `shapes` are dropped; an empty list falls back to all four. Reduced motion holds the first figure with no build, glint or lamp. Without WebGL the canvas stays empty. + ## phosphor-score Background. Two vertical staves. Notes fall, bloom at the playhead, then flare. Not a piano roll. @@ -71,7 +99,7 @@ Best fit: a long scrolling page that should feel like hyperspace. Not a static p Not this: `logo-burst` (one-shot explosion), `shader-gradient` (no scroll coupling). -Deps: none. Key props: `colors`, `starCount` (420), `displacement` (1), `containerRef` / Svelte `container`. +Deps: none. Key props: `colors` (omit it and the palette follows the theme), `theme`, `starCount` (420), `displacement` (1), `containerRef` / Svelte `container`. ```tsx "use client" @@ -288,7 +316,7 @@ Best fit: a mascot, an empty state, a corner presence. Not this: a full-bleed background. It is `size` pixels square (default 280). -Deps: none. The only `variant` in the registry: `"white"` (default), `"black"`, `"webgl"`, `"custom"`. Also `size`, `color`, `eyeColor`, `colors`, `interactive` (true), `blink` (true). +Deps: none. The only `variant` in the registry: `"white"`, `"black"`, `"webgl"`, `"custom"`. Omit it and the orb follows the theme (white on dark pages, black on light). Also `theme`, `size`, `color`, `eyeColor`, `colors`, `interactive` (true), `blink` (true). ```tsx "use client" @@ -303,13 +331,13 @@ Pitfalls: `color` / `eyeColor` apply only for `variant="custom"` (defaults `#7C5 ## ascii-logo -Character. Glyphs sample `text` or `src`. Hover shoves them in the `logo` phase. Clicks cycle `logo → scattered → fallen → returning → logo`. +Character. Glyphs sample `text` or `src` (4x supersampled, so rim cells get light marks and fade). They fly in on build, hover shoves them in the `logo` phase, and clicks cycle `logo → scattered → fallen → returning → logo`. The stage is transparent; ink follows the theme. Best fit: an interactive wordmark (max 5 letters) or a sampled mark. Not this: mouse-trail fluid (`ascii-fluid`). -Deps: none. Key props: `text` (`"23rd"`), `src`, `fit` (0.82), `cellSize` (11), `cellGap` (2), `charset`, `threshold` (0.2), `invert`, `color`, `backgroundColor`, hover/scatter/gravity props, `interactive` (true), `theme`, `label`, `onPhaseChange`. +Deps: none. Key props: `text` (`"23rd"`), `src`, `fit` (0.82), `cellSize` (11), `cellGap` (2), `charset`, `edgeCharset`, `threshold` (0.2), `invert`, `color`, `backgroundColor` (transparent), `font`, `assemble` (true), hover/scatter/gravity props, `interactive` (true), `theme`, `label`, `onPhaseChange`. ```tsx "use client" diff --git a/skills/23rd/conventions.md b/skills/23rd/conventions.md index d746f37..afb7dd0 100644 --- a/skills/23rd/conventions.md +++ b/skills/23rd/conventions.md @@ -100,6 +100,8 @@ Demos: - `*-demo.tsx` is for the docs site only. It is not a registry file. - Demo stages and docs examples use theme tokens (`bg-background`, `from-background`, `text-foreground`), never a hard-coded black or white stage. Check the demo in both themes before shipping. - Use `ComponentPreview`, `ComponentControls`, and `usePreviewProps` the way `shader-gradient-demo.tsx` does. +- One `ComponentPreview` per page. On a component page it moves into the full-height stage at the top of the docs, and the controls into the props panel beside it. Size the demo's root with `h-[var(--preview-h,56svh)]` so it fills the stage there and keeps 56svh anywhere else. +- Do not put a `## Preview` heading above the demo; the preview is not in the page flow. - `gooey-color-picker` has no demo file; the MDX renders `` inside `ComponentPreview`. Either pattern is acceptable. Do not publish the demo. ## Docs diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index b676a59..ee36002 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -41,6 +41,7 @@ Swap the component, keep the shell: | ASCII trails | `AsciiFluid` | `interactive` | | CRT score | `PhosphorScore` | parent `overflow-hidden`; do not expect a `d` key | | Burst, no mark | `LogoBurst` | no children | +| Glowing isometric figure | `IsoLattice` | lattice in its own box under the copy | | Burst around a logo | `LogoBurst` | pass the mark as `children` | Svelte: `import ShaderGradient from "$lib/components/ui/shader-gradient.svelte"` and `class` on the section if you style the component itself.