From d0350bf99c04bc6c95ea0b9a3e94b37930f9c15b Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 11 Oct 2026 11:46:08 +0530 Subject: [PATCH 01/19] Paint the Gooey Color Picker with theme tokens instead of black. --- public/r/gooey-color-picker-svelte.json | 2 +- public/r/gooey-color-picker.json | 2 +- .../gooey-color-picker.svelte | 20 +++++++++---------- .../gooey-color-picker/gooey-color-picker.tsx | 20 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) 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..4e7f184 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/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 ? (
\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..607bce1 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 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, 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 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 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/registry/radiant-lines/radiant-lines-demo.tsx b/registry/radiant-lines/radiant-lines-demo.tsx index 5e599ce..62df63a 100644 --- a/registry/radiant-lines/radiant-lines-demo.tsx +++ b/registry/radiant-lines/radiant-lines-demo.tsx @@ -1,6 +1,6 @@ "use client" -import { useRef } from "react" +import { useLayoutEffect, useMemo, useRef } from "react" import { ComponentControls, @@ -8,21 +8,27 @@ import { ControlSlider, } from "@/components/component-controls" import { ComponentPreview } from "@/components/component-preview" +import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" -import { RadiantLines } from "@/registry/radiant-lines/radiant-lines" +import { + DEFAULT_COLORS, + LIGHT_COLORS, + RadiantLines, +} from "@/registry/radiant-lines/radiant-lines" + +function norm(hex: string) { + return hex.trim().toUpperCase() +} + +function colorsEqual(a: string[], b: string[]) { + return ( + a.length === b.length && a.every((color, i) => norm(color) === norm(b[i]!)) + ) +} -const DEFAULTS = { - starCount: 420, - displacement: 1, - colors: [ - "#FF6B4A", - "#2DD4BF", - "#FBBF24", - "#60A5FA", - "#F472B6", - "#A3E635", - "#94A3B8", - ], +/** The stock palettes swap with the theme; a custom one stays put. */ +function isStockPalette(colors: string[]) { + return colorsEqual(colors, DEFAULT_COLORS) || colorsEqual(colors, LIGHT_COLORS) } const PAGES = [ @@ -45,8 +51,24 @@ const PAGES = [ export function RadiantLinesDemo() { const scrollerRef = useRef(null) - const { props, updateProp, resetProps, hasChanges } = - usePreviewProps(DEFAULTS) + const theme = useHydratedTheme() + const palette = theme === "dark" ? DEFAULT_COLORS : LIGHT_COLORS + const defaults = useMemo( + () => ({ starCount: 420, displacement: 1, colors: palette }), + [palette] + ) + const { props, updateProp, resetProps, hasChanges, setProps } = + usePreviewProps(defaults) + + useLayoutEffect(() => { + setProps((prev) => { + if (!isStockPalette(prev.colors)) return prev + if (colorsEqual(prev.colors, palette)) return prev + return { ...prev, colors: palette } + }) + }, [palette, setProps]) + + const stock = isStockPalette(props.colors) return ( <> @@ -63,7 +85,7 @@ export function RadiantLinesDemo() { containerRef={scrollerRef} starCount={props.starCount} displacement={props.displacement} - colors={props.colors} + colors={stock ? undefined : props.colors} /> @@ -97,14 +119,14 @@ export function RadiantLinesDemo() { snippetProps={{ starCount: props.starCount, displacement: props.displacement, - colors: props.colors, + colors: stock ? undefined : props.colors, }} > void } +/** Dark-page palette: bright streaks on black. */ export const DEFAULT_COLORS = [ "#FF6B4A", "#2DD4BF", @@ -31,6 +40,54 @@ export const DEFAULT_COLORS = [ "#94A3B8", ] +/** Light-page palette: the same hues, deep enough to read on white. */ +export const LIGHT_COLORS = [ + "#E0472A", + "#0E9484", + "#C98A00", + "#2A66D1", + "#D2347F", + "#5E8F0B", + "#5B6B80", +] + +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: RadiantLinesTheme = "auto"): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +/** + * 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) + } +} + const DEPTH = 1000 const STIFFNESS = 70 const DAMPING = 32 @@ -48,8 +105,9 @@ type Star = { color: string } -function paletteOf(colors?: string[]) { - return colors && colors.length > 0 ? colors : DEFAULT_COLORS +function paletteOf(colors: string[] | undefined, dark: boolean) { + if (colors && colors.length > 0) return colors + return dark ? DEFAULT_COLORS : LIGHT_COLORS } function displacementOf(value?: number) { @@ -86,16 +144,29 @@ export function createRadiantLines( ): RadiantLinesInstance | null { let options: RadiantLinesOptions = { starCount: 420, - colors: DEFAULT_COLORS, displacement: 1, container: null, + theme: "auto", ...initial, } const ctx = canvas.getContext("2d", { alpha: true }) if (!ctx) return null - let stars = createStars(options.starCount ?? 420, paletteOf(options.colors)) + let dark = resolveDark(options.theme) + let stars = createStars( + options.starCount ?? 420, + paletteOf(options.colors, dark) + ) + + // Theme or palette changes recolor the field in place, so the stars keep + // flying instead of jumping to new positions. + const recolor = () => { + const colors = paletteOf(options.colors, dark) + for (let i = 0; i < stars.length; i++) { + stars[i]!.color = colors[i % colors.length]! + } + } const size = { w: 0, h: 0 } let speed = 0.28 let speedVel = 0 @@ -223,7 +294,7 @@ export function createRadiantLines( // Reduced motion: no travel and no streaks, just still points. const warp = reduce ? 0 : speed const focal = Math.max(w, h) * 0.5 - const colors = paletteOf(options.colors) + const colors = paletteOf(options.colors, dark) const travel = warp * TRAVEL_PER_SEC * displacementOf(options.displacement) ctx.clearRect(0, 0, w, h) @@ -270,8 +341,10 @@ export function createRadiantLines( } const near = 1 - star.z / DEPTH - const lineW = Math.max(0.8, near * (1.8 + warp * 0.12)) - const alpha = 0.35 + near * 0.65 + // Thin pale streaks vanish on white, so light pages get a higher + // floor on both width and opacity. + const lineW = Math.max(dark ? 0.8 : 1, near * (1.8 + warp * 0.12)) + const alpha = dark ? 0.35 + near * 0.65 : 0.55 + near * 0.45 ctx.beginPath() ctx.moveTo(px, py) @@ -306,18 +379,29 @@ export function createRadiantLines( document.addEventListener("visibilitychange", onVisibility) onReduce() mqReduce.addEventListener("change", onReduce) + const syncTheme = () => { + const next = resolveDark(options.theme) + if (next === dark) return + dark = next + recolor() + wake() + } + const unwatchTheme = watchTheme(syncTheme) wake() return { setOptions(next) { const prevCount = options.starCount ?? 420 - const prevColors = paletteOf(options.colors).join(",") + const prevColors = paletteOf(options.colors, dark).join(",") options = { ...options, ...next } + dark = resolveDark(options.theme) const nextCount = options.starCount ?? 420 - const nextColors = paletteOf(options.colors).join(",") - if (nextCount !== prevCount || nextColors !== prevColors) { - stars = createStars(nextCount, paletteOf(options.colors)) + const nextColors = paletteOf(options.colors, dark).join(",") + if (nextCount !== prevCount) { + stars = createStars(nextCount, paletteOf(options.colors, dark)) + } else if (nextColors !== prevColors) { + recolor() } bindScroll() wake() @@ -331,6 +415,7 @@ export function createRadiantLines( unbindScroll() document.removeEventListener("visibilitychange", onVisibility) mqReduce.removeEventListener("change", onReduce) + unwatchTheme() }, } } diff --git a/registry/radiant-lines/radiant-lines.svelte b/registry/radiant-lines/radiant-lines.svelte index 3ca0525..1ca219a 100644 --- a/registry/radiant-lines/radiant-lines.svelte +++ b/registry/radiant-lines/radiant-lines.svelte @@ -5,7 +5,6 @@ import { onMount } from "svelte" import { createRadiantLines, - DEFAULT_COLORS, type RadiantLinesInstance, type RadiantLinesOptions, } from "./radiant-lines-vanilla" @@ -21,9 +20,10 @@ let { class: className = "", - colors = DEFAULT_COLORS, + colors, starCount = 420, displacement = 1, + theme = "auto", container, }: Props = $props() @@ -36,6 +36,7 @@ colors, starCount, displacement, + theme, container: container ?? null, }) return () => { @@ -49,6 +50,7 @@ colors, starCount, displacement, + theme, container: container ?? null, }) }) diff --git a/registry/radiant-lines/radiant-lines.tsx b/registry/radiant-lines/radiant-lines.tsx index 2015aec..608a1f2 100644 --- a/registry/radiant-lines/radiant-lines.tsx +++ b/registry/radiant-lines/radiant-lines.tsx @@ -10,10 +10,11 @@ import { type RadiantLinesOptions, } from "./radiant-lines-vanilla" -export { DEFAULT_COLORS } from "./radiant-lines-vanilla" +export { DEFAULT_COLORS, LIGHT_COLORS } from "./radiant-lines-vanilla" export type { RadiantLinesInstance, RadiantLinesOptions, + RadiantLinesTheme, } from "./radiant-lines-vanilla" export type RadiantLinesProps = Omit & { @@ -27,13 +28,15 @@ export type RadiantLinesProps = Omit & { /** * Hyperspace starfield: colored streaks radiate from the center. - * Transparent canvas over `bg-background` (shadcn theme). + * Transparent canvas over `bg-background` (shadcn theme); the stock palette + * follows the theme. */ export function RadiantLines({ className, colors, starCount = 420, displacement = 1, + theme = "auto", containerRef, }: RadiantLinesProps) { const canvasRef = useRef(null) @@ -46,6 +49,7 @@ export function RadiantLines({ colors, starCount, displacement, + theme, container: containerRef?.current ?? null, }) return () => { @@ -61,9 +65,10 @@ export function RadiantLines({ colors, starCount, displacement, + theme, container: containerRef?.current ?? null, }) - }, [colors, starCount, displacement, containerRef]) + }, [colors, starCount, displacement, theme, containerRef]) return (
// omit → window @@ -100,7 +101,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 +290,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 diff --git a/skills/23rd/components.md b/skills/23rd/components.md index a77ca94..e23f15e 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -71,7 +71,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 +288,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" From 86197b1d07cca9a38d28546a30724e666744803e Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 11 Oct 2026 11:46:08 +0530 Subject: [PATCH 03/19] Let Live Orb pick its material from the theme. --- components/landing-wall.tsx | 8 +-- content/docs/components/live-orb.mdx | 5 +- public/r/live-orb-svelte.json | 2 +- public/r/live-orb.json | 2 +- registry/live-orb/live-orb-demo.tsx | 41 +++---------- registry/live-orb/live-orb-vanilla.ts | 85 +++++++++++++++++++++++---- registry/live-orb/live-orb.svelte | 22 ++++++- registry/live-orb/live-orb.tsx | 25 ++++++-- 8 files changed, 130 insertions(+), 60 deletions(-) diff --git a/components/landing-wall.tsx b/components/landing-wall.tsx index c606ed9..b6097d1 100644 --- a/components/landing-wall.tsx +++ b/components/landing-wall.tsx @@ -13,7 +13,6 @@ import { } from "react" import { RiArrowRightUpLine } from "@remixicon/react" -import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { cn } from "@/lib/utils" import { LiveOrb, type LiveOrbProps } from "@/registry/live-orb/live-orb" import { LogoBurst } from "@/registry/logo-burst/logo-burst" @@ -35,14 +34,9 @@ const SHIFT_MS = 1400 * rests. The stock one is white on dark, black on light. */ function OrbPreview(props: Omit) { - const theme = useHydratedTheme() return (
- +
) } diff --git a/content/docs/components/live-orb.mdx b/content/docs/components/live-orb.mdx index 189408b..b9477dd 100644 --- a/content/docs/components/live-orb.mdx +++ b/content/docs/components/live-orb.mdx @@ -42,7 +42,7 @@ export function Mascot() { -`custom` uses `color` and `eyeColor`; `webgl` takes an optional three-stop `colors`. +Without a `variant` the orb is white on dark pages and black on light ones. `custom` uses `color` and `eyeColor`; `webgl` takes an optional three-stop `colors`. @@ -69,12 +69,13 @@ export function Mascot() { | Prop | Type | Default | | ------------- | ------------------------------------------- | ------------------------- | -| `variant` | `"white" \| "black" \| "webgl" \| "custom"` | `"white"` | +| `variant` | `"white" \| "black" \| "webgl" \| "custom"` | follows theme | | `size` | `number` | `280` | | `color` | `string` | `"#7C5CFF"` | | `eyeColor` | `string` | `"#FAFAFA"` | | `colors` | `string[]` | violet / foam / dust-rose | | `interactive` | `boolean` | `true` | | `blink` | `boolean` | `true` | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | | `className` | `string` | React only | | `class` | `string` | Svelte only | diff --git a/public/r/live-orb-svelte.json b/public/r/live-orb-svelte.json index 094df92..f757953 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..171d955 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 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 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(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 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/registry/live-orb/live-orb-demo.tsx b/registry/live-orb/live-orb-demo.tsx index d669003..8c98151 100644 --- a/registry/live-orb/live-orb-demo.tsx +++ b/registry/live-orb/live-orb-demo.tsx @@ -19,7 +19,10 @@ import { type LiveOrbVariant, } from "@/registry/live-orb/live-orb" -const VARIANTS: { id: LiveOrbVariant; label: string }[] = [ +type DemoVariant = "auto" | LiveOrbVariant + +const VARIANTS: { id: DemoVariant; label: string }[] = [ + { id: "auto", label: "Auto" }, { id: "white", label: "White" }, { id: "black", label: "Black" }, { id: "webgl", label: "WebGL" }, @@ -36,33 +39,10 @@ function colorsEqual(a: string[], b: string[]) { ) } -function hexLum(hex: string) { - const h = hex.replace("#", "").trim() - const full = - h.length === 3 - ? h - .split("") - .map((c) => c + c) - .join("") - : h.padEnd(6, "0").slice(0, 6) - const n = Number.parseInt(full, 16) - if (Number.isNaN(n)) return 1 - const r = ((n >> 16) & 255) / 255 - const g = ((n >> 8) & 255) / 255 - const b = (n & 255) / 255 - return 0.2126 * r + 0.7152 * g + 0.0722 * b -} - -function stageBackground(variant: LiveOrbVariant, color: string) { - if (variant === "black") return "#E4E4E7" - if (variant === "custom") return hexLum(color) > 0.55 ? "#0A0A0B" : "#E4E4E7" - return "#0A0A0B" -} - export function LiveOrbDemo() { const defaults = useMemo( () => ({ - variant: "white" as LiveOrbVariant, + variant: "auto" as DemoVariant, size: 280, interactive: true, blink: true, @@ -76,7 +56,7 @@ export function LiveOrbDemo() { const { props, updateProp, resetProps, hasChanges } = usePreviewProps(defaults) - const stage = stageBackground(props.variant, props.color) + const variant = props.variant === "auto" ? undefined : props.variant return ( <> @@ -84,13 +64,10 @@ export function LiveOrbDemo() { title="Live Orb" stageClassName="min-h-0 overflow-hidden p-0" > -
+
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) + } +} + +/** The material an omitted `variant` resolves to. */ +export function defaultVariant(dark: boolean): LiveOrbVariant { + return dark ? "white" : "black" +} + const VERT = ` attribute vec2 a_position; void main() { @@ -193,25 +246,27 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { } export function resolveVariant( - variant: LiveOrbVariant, + variant: LiveOrbVariant | undefined, color?: string, eyeColor?: string, - colors?: string[] + colors?: string[], + dark = false ) { let body: string = WHITE.color let eye: string = WHITE.eyeColor let mode = 0 let palette: string[] = WEBGL_COLORS + const preset = variant ?? defaultVariant(dark) - if (variant === "black") { + if (preset === "black") { body = BLACK.color eye = BLACK.eyeColor - } else if (variant === "webgl") { + } else if (preset === "webgl") { palette = colors && colors.length > 0 ? colors : WEBGL_COLORS body = palette[1] ?? WEBGL_COLORS[1]! eye = "#0C0C10" mode = 1 - } else if (variant === "custom") { + } else if (preset === "custom") { body = color ?? CUSTOM_DEFAULT.color eye = eyeColor ?? CUSTOM_DEFAULT.eyeColor } @@ -252,15 +307,13 @@ export function createLiveOrb( canvas: HTMLCanvasElement, initial: LiveOrbOptions = {} ): LiveOrbInstance | null { - let options: Required< - Pick - > & + let options: Required> & LiveOrbOptions = { - variant: "white", interactive: true, blink: true, ...initial, } + let dark = resolveDark(options.theme) const look = { x: 0, y: 0.08 } const targetLook = { x: 0, y: 0.08 } @@ -418,7 +471,8 @@ export function createLiveOrb( options.variant, options.color, options.eyeColor, - options.colors + options.colors, + dark ) const time = clock / 1000 @@ -485,6 +539,13 @@ export function createLiveOrb( document.addEventListener("visibilitychange", onVisibility) const onReduce = () => wake() mqReduce.addEventListener("change", onReduce) + const syncTheme = () => { + const next = resolveDark(options.theme) + if (next === dark) return + dark = next + wake() + } + const unwatchTheme = watchTheme(syncTheme) // Without preventDefault the browser never hands the context back. const onLost = (e: Event) => { @@ -514,6 +575,7 @@ export function createLiveOrb( return { setOptions(next) { options = { ...options, ...next } + dark = resolveDark(options.theme) wake() }, destroy() { @@ -524,6 +586,7 @@ export function createLiveOrb( io.disconnect() document.removeEventListener("visibilitychange", onVisibility) mqReduce.removeEventListener("change", onReduce) + unwatchTheme() window.removeEventListener("pointermove", onMove) canvas.removeEventListener("webglcontextlost", onLost) canvas.removeEventListener("webglcontextrestored", onRestored) diff --git a/registry/live-orb/live-orb.svelte b/registry/live-orb/live-orb.svelte index 96fc96c..f3abeac 100644 --- a/registry/live-orb/live-orb.svelte +++ b/registry/live-orb/live-orb.svelte @@ -6,7 +6,9 @@ import { createLiveOrb, fallbackFaceStyle, + resolveDark, resolveVariant, + watchTheme, type LiveOrbInstance, type LiveOrbOptions, } from "./live-orb-vanilla" @@ -24,16 +26,20 @@ let { class: className = "", size = 280, - variant = "white", + variant, color, eyeColor, colors, interactive = true, blink = true, + theme = "auto", }: Props = $props() let canvas: HTMLCanvasElement | undefined = $state() let hasGl = $state(false) + // The CSS fallback face needs the theme too; it starts light on the + // server and corrects itself right after mount. + let dark = $state(false) let instance: LiveOrbInstance | null = null onMount(() => { @@ -45,6 +51,7 @@ colors, interactive, blink, + theme, onHasGl: (ok) => { hasGl = ok }, @@ -55,6 +62,14 @@ } }) + $effect(() => { + const sync = () => { + dark = resolveDark(theme) + } + sync() + return watchTheme(sync) + }) + $effect(() => { instance?.setOptions({ variant, @@ -63,10 +78,13 @@ colors, interactive, blink, + theme, }) }) - const resolved = $derived(resolveVariant(variant, color, eyeColor, colors)) + const resolved = $derived( + resolveVariant(variant, color, eyeColor, colors, dark) + ) const face = $derived(fallbackFaceStyle(resolved)) diff --git a/registry/live-orb/live-orb.tsx b/registry/live-orb/live-orb.tsx index 645d648..d10a2b1 100644 --- a/registry/live-orb/live-orb.tsx +++ b/registry/live-orb/live-orb.tsx @@ -7,7 +7,9 @@ import { cn } from "@/lib/utils" import { createLiveOrb, fallbackFaceStyle, + resolveDark, resolveVariant, + watchTheme, type LiveOrbInstance, type LiveOrbOptions, } from "./live-orb-vanilla" @@ -15,6 +17,7 @@ import { export { BLACK, CUSTOM_DEFAULT, + defaultVariant, resolveVariant, WEBGL_COLORS, WHITE, @@ -22,6 +25,7 @@ export { export type { LiveOrbInstance, LiveOrbOptions, + LiveOrbTheme, LiveOrbVariant, } from "./live-orb-vanilla" @@ -33,21 +37,32 @@ export type LiveOrbProps = Omit & { /** * Evenly lit sphere with two capsule eyes that follow the pointer. - * The orb stays put; only the gaze moves. + * The orb stays put; only the gaze moves. Without a `variant` it is white + * on dark pages and black on light ones. */ export function LiveOrb({ className, size = 280, - variant = "white", + variant, color, eyeColor, colors, interactive = true, blink = true, + theme = "auto", }: LiveOrbProps) { const canvasRef = useRef(null) const instanceRef = useRef(null) const [hasGl, setHasGl] = useState(false) + // The CSS fallback face needs the theme too; it starts light on the + // server and corrects itself right after mount. + const [dark, setDark] = useState(false) + + useEffect(() => { + const sync = () => setDark(resolveDark(theme)) + sync() + return watchTheme(sync) + }, [theme]) useEffect(() => { const canvas = canvasRef.current @@ -59,6 +74,7 @@ export function LiveOrb({ colors, interactive, blink, + theme, onHasGl: setHasGl, }) return () => { @@ -77,11 +93,12 @@ export function LiveOrb({ colors, interactive, blink, + theme, onHasGl: setHasGl, }) - }, [variant, color, eyeColor, colors, interactive, blink]) + }, [variant, color, eyeColor, colors, interactive, blink, theme]) - const resolved = resolveVariant(variant, color, eyeColor, colors) + const resolved = resolveVariant(variant, color, eyeColor, colors, dark) return (
Date: Sun, 11 Oct 2026 11:53:27 +0530 Subject: [PATCH 04/19] Format the picker and the Radiant Lines demo. --- public/r/gooey-color-picker.json | 2 +- registry/gooey-color-picker/gooey-color-picker.tsx | 2 +- registry/radiant-lines/radiant-lines-demo.tsx | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/public/r/gooey-color-picker.json b/public/r/gooey-color-picker.json index 4e7f184..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-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", + "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/registry/gooey-color-picker/gooey-color-picker.tsx b/registry/gooey-color-picker/gooey-color-picker.tsx index 6dffdda..e97aea8 100644 --- a/registry/gooey-color-picker/gooey-color-picker.tsx +++ b/registry/gooey-color-picker/gooey-color-picker.tsx @@ -414,7 +414,7 @@ export function GooeyColorPicker({ aria-valuemax={100} aria-valuenow={Math.round(color.a * 100)} className={cn( - "relative mt-3 h-9 w-full shrink-0 touch-none overflow-hidden rounded-xl outline-none ring-1 ring-foreground/10", + "relative mt-3 h-9 w-full shrink-0 touch-none overflow-hidden rounded-xl ring-1 ring-foreground/10 outline-none", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover" )} style={{ diff --git a/registry/radiant-lines/radiant-lines-demo.tsx b/registry/radiant-lines/radiant-lines-demo.tsx index 62df63a..89629da 100644 --- a/registry/radiant-lines/radiant-lines-demo.tsx +++ b/registry/radiant-lines/radiant-lines-demo.tsx @@ -28,7 +28,9 @@ function colorsEqual(a: string[], b: string[]) { /** The stock palettes swap with the theme; a custom one stays put. */ function isStockPalette(colors: string[]) { - return colorsEqual(colors, DEFAULT_COLORS) || colorsEqual(colors, LIGHT_COLORS) + return ( + colorsEqual(colors, DEFAULT_COLORS) || colorsEqual(colors, LIGHT_COLORS) + ) } const PAGES = [ From 03f41e8167223548ea877e791263189bda9f2314 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 11 Oct 2026 11:53:27 +0530 Subject: [PATCH 05/19] Rebuild ASCII Logo: transparent stage, theme ink, supersampled edges, assemble flight, heaped drop. --- .cursor/skills/23rd/apis.md | 11 +- .cursor/skills/23rd/components.md | 4 +- content/docs/components/ascii-logo.mdx | 9 +- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-demo.tsx | 79 ++--- registry/ascii-logo/ascii-logo-vanilla.ts | 340 +++++++++++++++++----- registry/ascii-logo/ascii-logo.svelte | 10 +- registry/ascii-logo/ascii-logo.tsx | 23 +- skills/23rd/apis.md | 11 +- skills/23rd/components.md | 4 +- 11 files changed, 353 insertions(+), 142 deletions(-) diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index b8b33a7..1943e4d 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -317,11 +317,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 e23f15e..14ae94d 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -303,13 +303,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/content/docs/components/ascii-logo.mdx b/content/docs/components/ascii-logo.mdx index e05b247..be95cbf 100644 --- a/content/docs/components/ascii-logo.mdx +++ b/content/docs/components/ascii-logo.mdx @@ -1,6 +1,6 @@ --- title: ASCII Logo -description: An ASCII wordmark that dodges the cursor and scatters, falls, and regroups on click. +description: An ASCII wordmark that assembles on load, dodges the cursor, and scatters, falls, and regroups on click. --- import { AsciiLogoDemo } from "@/components/demos" @@ -19,7 +19,7 @@ Or from the public GitHub registry: ## Usage -Give the parent a size; the canvas fills it. +Give the parent a size; the canvas fills it. The stage is transparent and the ink follows the theme. @@ -77,10 +77,13 @@ Clicks cycle `logo → scattered → fallen → returning → logo`. | `cellSize` | `number` | `11` | | `cellGap` | `number` | `2` | | `charset` | `string` | letters, numbers, a few symbols | +| `edgeCharset` | `string` | `.:;-=+*~` for the rim | | `threshold` | `number` | `0.2` | | `invert` | `boolean` | `true` when `src` is set | | `color` | `string` | theme ink | -| `backgroundColor` | `string` | theme paper | +| `backgroundColor` | `string` | `"transparent"` | +| `font` | `string` | the root's font (`font-mono`) | +| `assemble` | `boolean` | `true`, fly in on build | | `hoverRadius` | `number` | `7` | | `hoverPush` | `number` | `2.6` | | `hoverEase` | `number` | `0.18` | diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index b28ddc9..0b2892c 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..cb2b402 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 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 if (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 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 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/registry/ascii-logo/ascii-logo-demo.tsx b/registry/ascii-logo/ascii-logo-demo.tsx index b8db8d9..5d87b3e 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 ( <> @@ -100,6 +81,7 @@ export function AsciiLogoDemo() { >
-

- {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,6 +308,7 @@ 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" @@ -226,6 +318,8 @@ export function createAsciiLogo( 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 +329,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 +366,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 +393,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 +419,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 +478,7 @@ export function createAsciiLogo( p.cellSize, p.cellGap, p.charset, + p.edgeCharset, p.threshold, String(p.invert ?? ""), cols, @@ -383,29 +508,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 +541,20 @@ export function createAsciiLogo( return } cells = buildFromImageData(data, cols, rows, snapshot) + gridCols = cols gridRows = rows - setPhase("logo") cursor.x = -999 cursor.y = -999 + readFont() + + if (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 +567,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 +605,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 +616,7 @@ export function createAsciiLogo( if (reduced || !p.interactive) { cell.offsetX = 0 cell.offsetY = 0 + cell.spin = 0 continue } @@ -473,24 +627,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 +683,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 +716,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 +724,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,6 +789,7 @@ export function createAsciiLogo( cyclePhase() } + readFont() void rebuild() raf = requestAnimationFrame(tick) @@ -621,6 +812,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 +821,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..a220b6b 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,6 +30,7 @@ cellSize = 11, cellGap = 2, charset = DEFAULT_CHARSET, + edgeCharset = EDGE_CHARSET, threshold = 0.2, invert, color, @@ -61,10 +63,13 @@ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -92,10 +97,13 @@ cellSize, cellGap, charset, + edgeCharset, threshold, invert, color, backgroundColor, + font, + assemble, hoverRadius, hoverPush, hoverEase, @@ -121,7 +129,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/skills/23rd/apis.md b/skills/23rd/apis.md index b8b33a7..1943e4d 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -317,11 +317,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 e23f15e..14ae94d 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -303,13 +303,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" From 387ab5a38677905d8f904ec32afb8d8714c12811 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 11 Oct 2026 12:01:45 +0530 Subject: [PATCH 06/19] Declare ASCII Logo's font and assemble props in Svelte and only fly in when the source changes. --- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-vanilla.ts | 13 ++++++++++--- registry/ascii-logo/ascii-logo.svelte | 4 +++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index 0b2892c..c5c0e9e 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 cb2b402..abf1b71 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 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 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 if (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 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 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", + "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 })\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/registry/ascii-logo/ascii-logo-vanilla.ts b/registry/ascii-logo/ascii-logo-vanilla.ts index 85d99a3..5917ac7 100644 --- a/registry/ascii-logo/ascii-logo-vanilla.ts +++ b/registry/ascii-logo/ascii-logo-vanilla.ts @@ -313,6 +313,8 @@ export function createAsciiLogo( 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 @@ -547,7 +549,11 @@ export function createAsciiLogo( cursor.y = -999 readFont() - if (p.assemble && p.interactive && !reduce && cells.length > 0) { + 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" @@ -793,9 +799,10 @@ export function createAsciiLogo( 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() }) diff --git a/registry/ascii-logo/ascii-logo.svelte b/registry/ascii-logo/ascii-logo.svelte index a220b6b..c7b89ae 100644 --- a/registry/ascii-logo/ascii-logo.svelte +++ b/registry/ascii-logo/ascii-logo.svelte @@ -30,11 +30,13 @@ cellSize = 11, cellGap = 2, charset = DEFAULT_CHARSET, - edgeCharset = EDGE_CHARSET, + edgeCharset = EDGE_CHARSET, threshold = 0.2, invert, color, backgroundColor, + font, + assemble = true, hoverRadius = 7, hoverPush = 2.6, hoverEase = 0.18, From f3eaeb5d54ceb79ff7523eb99b5eaf9a702325b1 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 15:51:15 +0530 Subject: [PATCH 07/19] Add Iso Lattice. A lattice of isometric diamond tiles lights up solid figures standing on a faint floor grid: a cube, a staircase, a bar chart and a coil. Every few seconds the next figure builds over the last from the floor up, with a bright front where the tiles turn over. Lit faces close into solid tiles and shaded ones break into grain, a level glint rises through the figure, and the pointer lights the bare lattice like a lamp. Each figure is drawn once into a mask that every cell samples, so tile sizes hold still and only the light moves. Amber in dark, burnt orange in light, transparent over the page. Holds the first figure under reduced motion, pauses off-screen, and recovers a lost WebGL context. Ships for React and Svelte with a docs page, demo, OG card and skill entry. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/SKILL.md | 5 +- .cursor/skills/23rd/apis.md | 25 + .cursor/skills/23rd/components.md | 28 + .cursor/skills/23rd/recipes.md | 1 + README.md | 1 + components/demos.tsx | 6 + content/docs/components/iso-lattice.mdx | 111 +++ content/docs/components/meta.json | 1 + content/docs/getting-started.mdx | 18 +- .../og/docs/components/iso-lattice/image.png | Bin 0 -> 40964 bytes public/r/iso-lattice-svelte.json | 15 + public/r/iso-lattice.json | 15 + public/r/registry.json | 26 + registry.json | 1 + registry/iso-lattice/iso-lattice-demo.tsx | 195 ++++ registry/iso-lattice/iso-lattice-vanilla.ts | 921 ++++++++++++++++++ registry/iso-lattice/iso-lattice.svelte | 85 ++ registry/iso-lattice/iso-lattice.tsx | 121 +++ registry/iso-lattice/registry.json | 31 + skills/23rd/SKILL.md | 5 +- skills/23rd/apis.md | 25 + skills/23rd/components.md | 28 + skills/23rd/recipes.md | 1 + 23 files changed, 1654 insertions(+), 11 deletions(-) create mode 100644 content/docs/components/iso-lattice.mdx create mode 100644 public/og/docs/components/iso-lattice/image.png create mode 100644 public/r/iso-lattice-svelte.json create mode 100644 public/r/iso-lattice.json create mode 100644 registry/iso-lattice/iso-lattice-demo.tsx create mode 100644 registry/iso-lattice/iso-lattice-vanilla.ts create mode 100644 registry/iso-lattice/iso-lattice.svelte create mode 100644 registry/iso-lattice/iso-lattice.tsx create mode 100644 registry/iso-lattice/registry.json 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 1943e4d..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 diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index 14ae94d..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. 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/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/content/docs/components/iso-lattice.mdx b/content/docs/components/iso-lattice.mdx new file mode 100644 index 0000000..191d831 --- /dev/null +++ b/content/docs/components/iso-lattice.mdx @@ -0,0 +1,111 @@ +--- +title: Iso Lattice +description: A lattice of glowing isometric tiles that builds solid figures, one over the last, from the floor up. +--- + +import { IsoLatticeDemo } from "@/components/demos" + +A fixed lattice of isometric diamond tiles that lights up solid figures standing on a faint floor grid: a cube, a staircase, a bar chart, a coil. Every few seconds the next figure builds over the last from the floor up, with a bright front where the tiles turn over. Lit faces close into solid tiles and shaded ones break into grain. A level glint rises through the figure, and the pointer lights the bare lattice like a lamp. The canvas is transparent; the parent paints the page. + +## Preview + + + +## Installation + + + +Or from the public GitHub registry: + + + +## Usage + +Give the parent a height and put copy above the figure in `relative z-10`. The figure sits in the middle of the canvas, so a hero usually gives it the lower part of the section. + + + +```tsx +"use client" + +import { IsoLattice } from "@/components/ui/iso-lattice" + +export function Hero() { + return ( +
+
+

+ Builds that assemble themselves +

+

+ Every commit compiles into the same lattice. Change one piece and + only that piece moves. +

+
+
+ +
+
+ ) +} +``` +
+ +```svelte + + +
+
+

+ Builds that assemble themselves +

+

+ Every commit compiles into the same lattice. Change one piece and + only that piece moves. +

+
+
+ +
+
+``` +
+
+ +Hold one figure, or pick the cycle: + + + +```tsx + + +``` + + +```svelte + + +``` + + + +Under `prefers-reduced-motion: reduce` the first figure holds still with no build, glint or lamp. + +## Props + +| Prop | Type | Default | +| --- | --- | --- | +| `shapes` | `("cube" \| "steps" \| "pillars" \| "coil")[]` | `["cube", "steps", "pillars", "coil"]` | +| `interval` | `number` | `4`, seconds per figure | +| `color` | `string` | theme amber | +| `size` | `number` | `0.6`, share of the shorter side | +| `spacing` | `number` | `8`, CSS px between tiles | +| `glow` | `number` | `0.6`, 0–1, `0` for none | +| `speed` | `number` | `1` | +| `floor` | `boolean` | `true` | +| `interactive` | `boolean` | `true` | +| `theme` | `"light" \| "dark" \| "auto"` | `auto` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index d71ca4a..934ed7c 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -7,6 +7,7 @@ "radiant-lines", "ascii-fluid", "ring-tower", + "iso-lattice", "---Shaders---", "shader-gradient", "shader-anime-fire", diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index 343a197..9e54456 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -52,12 +52,12 @@ export function Hero() { ## Browse -| Category | Components | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Background | [Logo Burst](/docs/components/logo-burst), [Phosphor Score](/docs/components/phosphor-score), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid) | -| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky), [Shader Metal](/docs/components/shader-metal) | -| Footers | [Stretchy Footer](/docs/components/stretchy-footer) | -| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | -| Pages | [Dithered 404](/docs/components/dithered-404) | -| Sections | [Folio](/docs/components/folio) | -| Components | [Gooey Color Picker](/docs/components/gooey-color-picker), [Typewriter](/docs/components/typewriter) | +| Category | Components | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Background | [Logo Burst](/docs/components/logo-burst), [Phosphor Score](/docs/components/phosphor-score), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid), [Iso Lattice](/docs/components/iso-lattice) | +| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky), [Shader Metal](/docs/components/shader-metal) | +| Footers | [Stretchy Footer](/docs/components/stretchy-footer) | +| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | +| Pages | [Dithered 404](/docs/components/dithered-404) | +| Sections | [Folio](/docs/components/folio) | +| Components | [Gooey Color Picker](/docs/components/gooey-color-picker), [Typewriter](/docs/components/typewriter) | diff --git a/public/og/docs/components/iso-lattice/image.png b/public/og/docs/components/iso-lattice/image.png new file mode 100644 index 0000000000000000000000000000000000000000..dbc234693101d16422f1686ebcb54100aaf0589c GIT binary patch literal 40964 zcmeFZc{tSV`#xT1(ViYkg`P5GPqyrB(u^(3*te`PgtD(m5<=O^$dYZC!Pp67q!48t zV`q|zF&Jf^F?{cN-p~7cd|IB*-{0Tyd;aLCL*q5Cd%LdlI?waEBX4M{@85HL&$eyb z_Cv2<)!Vj>tz_Fa7Vq6V!B2E92AG4t*xj$2+}*Zq?-Ayo?c0zqj&0j^Y8&+GB?I3V zvtvFryzp16-&S8xlpAW}d03pDu*DzZV7naA9YEE)vUex{DO~k+b<;oC3~pSE+>>vh z_8>d!-tOJ45APg04foe!c`#EsPLcaIZW6G9^rJ0)m}M|*Efk071*^0V$drKj%CB1& zrj%liS#M)K*0^owxqI99{f~dv+;91~d$K_p~z)v;OwU*R4bEh5r71^;+)j zDP-|v`G*0iv+QNt$M&0nS~n)0-)UgYnR(>a+uuI<0*CP_>r=nZ5EwvlqGTouW?S0D zGdq9#|w`D@DViP-PUZxp^L_zx@N z&R=q|@!}7&`roNE7qY#$!*IJo=(T;nee(adq}=~5DRWL6|6S61+y6bKe;=QJPwDo3 zHU9?Q|0K@;KLuV)%I=*9PJXPb-6`POomqTYj{bu7!DaWphrdaKH5|;$jN~wC)-i@V z(+;FvQ~zzc1bMbY{hW2o%nxQjNC1IC%ZoS;=3Mp*!Recn&89OY7`=?MbM3r_Gx4TgY*ey|Cp_;)HhCvTTst zPWGMb|IMZUBY~Q~25FUxy)gF7KR$zY*s~UfCdO>Pzn$ebXv=5;z#cUg%;avT-(nDT zmgqgA!X?Lc4|4wso6Moiuje%8QNR9A^J-wN8`^xavGbStJog{0mftvY;qq^h2$;$& z=9lke@%|0OG3o{HO}x0H|8!f|rPugLeCoNI-1x6Z1g))KK;jl+7+)76=Xc5Mu9j-> zMsM&9BbxHA^`7i&>ybwU*CLMytg#;v@Z1{NbaS<-)p{|l9(!_#qUd@2)Ne7|$LrQI z4>Nr}Ip6THX^%J9n_#qelS`nqiWD5M)vF<~9;ZTk7qaoKb#pRqb8Lwkr@F$oW9z`k z3Ag$Q9i?w?Kg%S&>+Px(&{-*W>{_9INSz}2P4R4X*86a6R3vYPbo2Utyrk+B4&^U7 z_Q#;)uk#!@WCPIC6?RdEStR(uSd#>zC>=?~%`o2YHoh zW~Pd3K1BsnimJ8K>t63Xu{QB0!Y9!7~>eP?twnNg*VXM=MWoc8sjEQwS+! zivdj<0Y4b<2>2b!cZULuik1wmEct|XEGLPTzUA1gr4(!AZ8tqlwn|PcBMss23g;)5 z$bXKuT1s`TN(lkOb}k-ADmuohe!RzezpK{W)E2{Gj47^l)`KK#tK_C~7+?y^U~yBz z1((+h zM7aP)AZ=}~;^}HfEh^~i)nLD_-lf(rcG}HMJeRbMM~iByDSb)Q-#}n-{T6Mx;WJQc zui$MzE&CE5t3qqv_jCFv?PYadzZ})C&uup@?c(`dJ>-!3`Ng@F7tKell-B7>WI{qK z#g9(KhumqXdv`iodU?@_ofkLo{k;UKn=)-~h$WDH3FdLfo;0ErLy6@y=Q`JFEp%Lb zyNHH$&;~RSjmg?h#L`Zqc$=bNc=UU<86gF&lfjQpo( z81Ep-*O9V0Au6j~yj`VvLhd4kC6~2xjS(EiNSY)2SuvH2HlNz+)AkXtAp4;L!q9`fz+q3WzdLZ;l@_$VE3+;fsTAct-pe!#+`N`z_T^ zkIrlimf8j=Ig)dq&3`+_pyy}D&TQg4*^ipJl;$-D#K`ntx2*2Tbu_rwRCd1DePla( zp~4a437a_(37&3!kq9A&&__r#`h1;)6Kyp{ZuH|bZq)`wlceH@!$H+!@iT?{$d!% zkKr!Ucc=H5*8qP(Hh(gQzW2DC%iuZuVyB{D2)*fOLpQ^hVX-wp^%?bO<(2z-T*f(( zbv-^XiWo>umCN&eLayJO^(^tiQYxu|liM zW0`UDe|&qEQ$@Wz%0}$~%!W*j;Pt;nP!VJo9YCF6FRY!vT(jUv&ADN?Ot@dw8zn*u zpuVbJ{Y1G0A@Xf4?80x-C_aLXEipV4x4OBiKHH-LrJj!X4%v}D3FX`7ShX)BPaE}w z3ceo0WI26)_w-d9k(Bg6|I;)$Fj#rrmGJ;7qzgV&z_1(&(YQ8OLD1eD^C8VW%`C&P zJ~O`qooD%08*rMvu=617$4D4z!`Kk9|K1K=PTtH=k?W&+g-O8u$RuI&d`M{Ss+G@}SHkICMP0cWrX&rG zVLPoq`|d71$E^&%=kxs-3jOmza&3=-)r765C0YhsK5j^}^6cGPp zSuiJW9~JT~Je^_@K8IWVN{<$=eJ9bd_Go^pu(V}nvqzmTMn%PiFJvP!WU)hzv0rZV ziKsw^B?3c4Si?sgI$sQ>og21|Q$`1;_a6@U7`iKdsv}*wPQ71M)4HH`Cj6-7b*{$) zm)F|AQc8~-&r0g3tlOQx=rkuLv)|wJx>X2ajPKRnn^U_4e#96TJ@%a~$S%0=M}Z5y z%+84u#-jbSI4@0qgY68*4iN5AMm`Lq-@1?xpab3b?cQ;MGP`xQ1pq0(l5jH7dx0@BP(zoU_C{a;hv(v`HK z)#oB}>W)2(?u_W_S+5O7ohT09!C5Pr%Ha(2ACjL(B8pR#42dSCCQ#bQl}slr z$(c}MPTe5~xV@0##z)qJOQzNB97afOA2Q^br$j^XfT?HC>&?RQ&hLv;U087sPN+MN zd*kNQ@JYpl#@>%PY-x}POMfy1Pn*F^sZ7n5cg@Q*>-ZR(BZ?~hm6`L9RaEKk?p3AA_(VnWjTq^+^)l^ zwYRjOm4g*(468_V{lLCwZKZXxGj0>M%V|kU(Gcn_ak-uRCC=K`;p`Z9-PLfS(eSp) z68}5TUVG;DwPvgVTr!g3*OC%E*!>bWfBbpzcbMS&SrmtrX}6MLXzttos00D~hHHu( z#)9d-Ss7A}+CG=Bv!7*%yA50C3O%YNWI^5}CPqO@64zj=Mv>>t6h)}YiJlcZzQVV+ ze0;qv>IZt+I&<~A2(0O>e0D0rsR(V_wP^4pa}Ph{PU43>8E(+CMNgB0({XP7rsjsq zNzWR~>WhQ$LV^AAR zjO{+UVRhm$7Y9|>m9tRCi;7#N0rWqwTa=_N9qyy>7=FX)3gPmX(iu#g%Er5l;9>G> zO70PxA}#Cv0Q_13AUDPifjlT~pv|Cg7G4`*ZMZi9uD~}yxn-K2C-zW(zmbI(JW!s)*wu>leY{ch z0p8g-)%lv7THzN0x3&zw&zvBwT`M~*E)^UY?`h&cT9dQnF*_(3Jo|iy*51c)Ce8)C z(Ipa^E?(fAtzTxqJYi$5w3QdrJrEY})tcXEN zeG03ffjEgufcmkE7NsWntg2`n`T{AbcvR0@4j_&CR&iHq`sI9?7t0|Q_O+=J}C2>blRk zUn{c)(BY~%(O0>^cnAQ^tLTh)gHlEA&QwJSlPw{?z}3atVbf6iByDH8abH{FU4NN- zx9+7#JLxsII=|7u`oM)kyeqWv$YE(;G%Uv-;<+ zsqI)cG_r@hXljb6XX^*BHl=P`ZQcSLW?iswS^p@Un^S{-=#Mw&LUWtPT_@HD5zZ}7 z<2}fguUuS3$NrSu@IlcDNfl3-K)muJNpZF$ECdu>+3Xn8;a0woPk&Vn$|y~8LqH~6 zv%p3JT2weS!;l-ll|A28Lveh8Uyx!=@P3WjwI;S}#atD`3Bq8kXrw27u6otj4yQ+7 z%-DLNLtgah@Qz!-D$_=LH!*FGwXszSE8usXT*x#b?Mno zAy*;}%q{z*wK_$m#X~0y-h%MUag~-DuY<%TdOc$fkn9h8629Yzf<%RI;le0<$HA&j z!{?29BTVd0(n?o))k;lb)AtD9Ocrn7jVVr4EO9Q>UPXyGfuKBKvAb^L)0t~)*bgsK zH03Et273}rO!taxSn&7cdOg!tUhd1;cc)bCsFv(;hPlnR<^;cH9aV;>!~NOUyZDw8 z42^HTo|>8YZV|R~ofJ4j*i6{kNZk6Yy7@k2BO~McNZocZY$I*XEhLpcWE|wr4|h5X z^Xm4@yh2|#1L*4RQIi24_T|A>cc%oRcNv0&w&>!D0Yw(SDRX;AJ@|j3lW7*q;`8~Jr1bIx+m)r+QDhcSi#7xDd?kjZ(#uN0J4MKlQ@6^EE zs$pl$yrS^TvTd8PmSP1Jt@aTiw37LXSMpRSe7l;$L^k@Yy*ssz+F)maxdjIa^p8}% z3n9T}DukUUTs~rJHuh|-O~$R>GR;>`Nj~OR9E@%5g|{CQKyguzPg`x`x4O0{swznp z)tig@!5fXCVju$YsVG5J$Wl(*16&t~()O7}SM~J;mp9}{fl{wVtbH-M9jX0|K3j}c zAHnD10MyRojliZ)b8liFi|e60fS^G!>s?mUu-S6lcwhW1|No%N&fN=5-w7%U)I7gt zE|);ML0S%f+!#^91-6mzEX+fJflVBlxKzfL2zlpurSjWkt5H&;p8k$!jE><7Cqg{Q zEZmTWg9|x8Un5z30D|DGaOHLQVM|7q^@B3f=j)`G(G@Y|Gx7I@0-^S-qkdCqQ&xUol6bL-j-4mB zirj)kxF0xlAk|o+!#EEZ{;;e}d5lwp2{JUTo4Kw~C6%#=36hHiTJ8Rp2xVi88a#k^ z^Epy@hc*Gi!kk3i(s@`o=N#PXyj=A#4kqbHouFXIh5 z{DUB6iR(t6wCwXrckK8bNBVKk5mpXk8G#Zm0ZgyYirEusQ)6y>yjl0d{X8>q^0Zr9 zAEdh0e7@om3fFTokOGc#X(3xL6dj90CS&9_%DYrj4fxaiTDYMp_RsV8sA_bPBsN_3 zau}78jw*(#{aB^`E!YCSItFfSuhmLPnVvE6lkt3aNY$h1!4B6?_z>;qPd=okS-Upf z&p^IWboihbyjF5kd0fiyl08+|&Gw@8#owsDjhsqAh$bdA&L}S9-faEX3s6MZ_PY_^ z+pf-nkz`BLKKHcI=8VIs)?X$3U|t~F0<3-V&mS3vziHr$Oky|h(D_3pQop{H7Oj+WTOFF-<(};r}9LbRX@1 zll497btn(p4>0L3AvsBux!8xL#e&C)GUuGqw6DXRSL97faEm8S{BF7a7uUqV6qA|$ zSJ&V7uT^6wP;~yaYW$U!8vogNf}#KgBVo)Ih6 z*R1})rv&Dza{4U^Y)w{1FJ$c20bkl+KycqOzUV|0P~y64#Lu*kQ5LWaop#cj z0dKy>E*ew}#P}Mc$>Ov$Qt;1YiaR87JG*GSK<;h+lBkTvpQK)SB6~R#Z2?#WCW7m(GMVa~iDy2Z$V*Zl7$D_i}YwhrJNk`{+RM>g;+hd1lBm zcp_p_>Zrq(xV$J3vmlgd!Zb5AD&C@_u0&WCd9K5qa_-Q zi2=-cXk)L{9;cgHRb=fyAu9@1+Fb7nDXtCS3z(G*`5@W4*34xlx>||SPMdM;N*~L{ zfyw9@&{P%gx52IsBe2WhIdkptbq-yix>#CJl|wc+KCkM%hC&R7uU>dmr51aslwg8v? zvQZ64eYjl1B`WX)(p_0JD4I^gHd1&@{7vF>c-cBIZ_Vic)3{o4BDubGD!yU>Uqia+ ztf)r(2xwkaZJ#W&g~g z>dZe)KweIya88*vy3&0WxOCwj&dB(ECVLy0M!ewJ!P1^7=SvItjV}@@f_3Iw zOh(Kx^G|*`A(SxYcFfCZ17YDrP$#ge&I5N^8=4dV{Jd>jMfy6*l_Y_Zc@QP0r`ASDn}(&mf-djHs?8lB#D^zt54v#}o9Ig><%7It zfvCNkiOwneFjJnJkuCG3jLU^N9|+UOz$WRVBRp+`^;&qntD1QBm&5Y*@3#Z)Cyt<4 z^i}7e5G}!IE!|$IPi>Z6L2mMQH{7zJ7CY7-)lLpt3TB9f=2kW%<>8t{_u9J_g){l8 z{3V{Y_Xbyu!p)RNqB$yn6nS?^DhH*Ac8YQ|&k_u|( zK?ZZy@BIwoQ%td-LB}Ub=?ab-BthdA&_ec2dI)9&-dXZMA8SXdzzfKJCIt3n=9_o{ ztLZh>H&4}hO;!;z^YP>X!cxyBFi--^T)$K(5|$`d{^9&s9AV`crokV$ zACJR29pH(;ayRaJ7xt5gbWg+s1nEj4r#rx8y6j)yuZXUmEQ@CW+Pzg=t5Z<@#d0@g@coT8ZEkC17 zco!!k;6tL2epXx$%Un$T>K>88Lt~b=@r;!!`P`pSO=HppfCh#WrPo2sAE$gn?lkU7 zCDX?>_8#s`&`ZeUxLw>B1eDm~vGR+s*=yb2=T zG=pghkMqRnVG!Et^Z+_~Iuh6V5JHEDz0 zs>B8rP44_zS9ISI2MkoQ0$Sn?YoDeU3x&8nnI2yK1w#ujVl9>9)*Km?aog{XP%?kO z_cp+_8Ya{Ey!dgD$ z&}}f=v2{PAJ*LP9_TY2rp8IpA7jeT!%*JwW_z3BrzB!fVvtrxwxPW}OB`K07!ROsueb|0M1ovA3B8b2QOS{6L2ghPc#*bLTJ@tNX>sf3_#SQwIIHgaH9mphpSk<7-UR9g7k46Vqs@eL%Gq{6Bjqo&L)xL)Wluld& z6B?0{S5ZGjt8J{}Qedm>sh^KWqbqi=>8kT28H9dFm6MQz-s!%y3$7_nOt|+Bft>H3 z;Ws%X^_4J97U9GYZz>U0nR+mVsBU=|3ODzrS%; zyg=;{ob+qs|9Sx?p8R=b=Gv>KJt9|8&kDkQ1eG=x-k6!{p7)yRo2Fn+7vT9z`b`l< z({!tVxyt>xc?C)#LE?z93DhXXtpW=I2GYQnthfQ18^(K;daSf*o!#$8bruj~LSoX09(0s26+@ALpkOx-G^ zHwVfP2=!X(QCBC0gzcBTHSMJXKwWT3V>>J=9dsA%UnK%?#Tn!Zml?J#22TBHn*GEl z+Ryvjy0KE386TcVN5VD8K4CleJJbA?|C1=0ofSATtmniPVZ>ZvPT)p0p55`>lj|;VLWI6!ST|;ju%Xdj#(o5G%Th~PYnVT zUCWm}hl1~IL-5oYSp~_wTKr56*$UWVK5`QThnH3kDfYuQNk1kl5J^5UBgvKC&z*`? z7n(tXYt>L5dO&IYb3%3;q0LhSjWyuz&)_g7ZG4r>2GF#9Ca43Phx0UK5{I$rTh*15 zT4|W6yDva}fV0%t`@Z^oXf9|oVdRtH#=ufs$h6!_|A~6=Qdt{%9+J`bRlaB<^URY(aKLwyunxv~OeP4y#UME;UI%@mHHPBf<2binax+#n?eltsDh zqTI2{n`u7dWJ$HE;XF1XHh39>K6)!d{;tDP=|QE1i+3EmO-WX5ADFo;{Q6q&Pwkq_ z{zH)CuOmf4i&*gd=&PQoQ2?WZ-#$7lgWm!L>M`Zlagev2N{b(+142Oh-+XXHJHLSC zDnfW zG7cNi0b-^B4x;LMo6h-!9AM4zB2tTH%2VAME37F9AK{EwdVn7bsnR&~#)7K)pH}uU6nkVekgbJ~g=AY^g5DFcJJ_ znkr0TFk%1N@_-fo)7VPz`g<#fh8Dmg4R_b-bjQrnCA>?BrN_&mQBg~4Dx z`P^X9kFM%UmBS8vnjjBsyy4sY2vTa#H0A7O>2kJZ2*V3M8LQZTas&h*HT(eJ7@ifr z?r&#Ho>y1{4FT6OPzAoVW)lzCkDSlI)*#G!{=gM(%7cA^NRKVZo1=ojI4`+;Zmgmq zt%@A7G0E36aD$_!|LQM2n8kd|B$LWvjIMYjmd{)D%qm#kBo(Ir+~LFXr}gRDWz4X~ zOu|<&#Z7jt*G{-5u5Tg=ORvrMNy~_x3?{;jAZ@Z*e2SF9@{MXdV{VlH9@5eWG)YMq zSXGqeQm>=ftov=fR@*=+*MtSG$2NoW5ZRW;+>=Ho_MED&!xj98v)S}%ejxYid>wWI zp(GIxFirM&S-*gOLIya`UCf5ONK}b^^D48<`$}Pw9z6qffZ;fyZCx-2VAU%|CjS$z zEBM*6_Zv>Av}8?Jc;y5rZI0qRE)VuOCdEg??tjHSM8Eb_@7gO~bE?3uPc~cls@R83 z5J;v}s=1?^2tV&(nl2M7dJH4cFyS=gfsw=|^wKLI(8Od3SBa6;iWv>2&nfP#*$s@1 zyGUSn=iwS2-+Jv1kzC|`ru|2OQ%ruLP`aO(NJ9pFbZUmS(QzGEDSbahYEkh_jtyNo zmFUT2WX6|PjpDJ$cBP+F@;M+5h`mb+m6{L= zhFuiHg;Wg*euFJ?u1+$td=;?As-I=UguSpn{L7-L<>PWL88=`-*BXJBb70Z+$Cdp( z!Qmhy(bLY!Y1F_eMSkps-4mzSe(-v^SGI!oZdt*SKIx=c&|KoQW@k)DJaFOTy;_wm z9newnaNMjb4swTvV0kY*TA^Z~j4eP5X$fkQCfvZ5d0dPD{(e0|MRWaUqoTGPQ%md+ zyQo>%T*&5B$jrPH{GF5kO4!wZON?S*Q zo>O!NsO_+yjV)Q;b43_^B7^Q751X%`xP1S@(paky6NEDarMrQB4YRL}66W;hs`Dp| zD+Tl+FN1a?mq8>)mnj0%i*zS@ebH7~|BReB|G8xUJ1Pq|K73i1!VFt&r@LXdoMKLn zeWSjN;Qh{Qlw$xG8^2-cqhfZqLnd`|6Q(&G>sn>J!3?u@X{Q*Vx#a`{wCH9Su;gOG zZmkEQ$w{aeccPe0Zwwxv(FrCP4*|uwAQ6E~M%DERtyI4C7%aIN+?TJ*Q28_KK_4KB zGc+@ZV{$Sw3!%Gsuiw7Ez@UbxnrH@r5~8c#);8M}l>S;=F#}o$m(l^ub1Tclad{be zeF4VkG=7NJ)QzemEqi`)+!arNgky8X=1S8qwnV`GHoOBU_}^=Owz2*z6a7Cc6a9O0 z)Bh*M+|ZA%0OJJ%-ava8bk`$kNZZ!fxeKu+VxZz2BY6RBg}OH7TUd#U7}yCKgbOD#a{C>E%qm>55;D#GpGL`D@kHr{A;-R&qbFG3 zke=bfNf<&#t|q<1P@dhYn&2^aHN(ESvVXv}#UpCPk%=y`L18OzX!y->@`c_ktx%jcU0ztzPpggLMV$;@o4~Em zS4=AT`nM|a6wi}RZ!@vS&VFym&!2LTw)seN0zikzT?5KwLGBOYKok2sCD-t6zpDGk z#>)rdcky`M_>QEdEFXi|edjpHkO|Fg$AH?2KGt>yQr-vJbi%8G4{#V4#YwUkwnqxa z=-BI64^<_~@UB#{8Rk+)3KAYV%u@iC88@+lS%a=2dB6R0^f;yO@8%f|;^s3{paW7P z3>%L9;bmn|#l|(MGo{>Z-pb@7Vl(#xfzJvcpEm2f0zDF~zdAmIi6Vn!LD%eyJSckq zaq0X?_Yoo}QS11{kD9w*Qdd-m0o$1sdV1xTdmruOb4= zyN+2V0?KAI_-p_R2ChvD*kNdBK9bTn41iq1zy+YhWrYkZT@6y1qbl&IAD28Ey&YU+ z6L|)7gq!8Pie-Xko=Z%fIX*0vdA~;6i@7TYl;Pd)=n-34qJn>hM0sUV+&W7_cmiu4+72{z>KX*XK&fsI!kdF6HiLP2@3Wl zPuJkiMV^{PfWn#$PH%{d5A84))y;4N4dvee@0-lLq$Yl~r%S5y!kOB0 zwKmz*tb&&-DNQwRZEk20R>#2ACJew8jVjCNZf!Dq^!e3Qw${5=qXH_~3!w=D>!9fB zv;w?zn=PkKGdx-+&*zgr53Wmg#N|j(5$IX+2^rWgJ9tX!DI%^ktPXK4~pLZ3go zF)5@Y0-6h;!7tFq>RlQw5Y)OUB%NIy$d+#%Zo%aN{tY4 zAeu}9_mFtb)5!tOBNcP-jCCM*z_?gD6mGCjR(y&Q5pd{;qDfVh0%=z8q3ejQOn#o+ z6psr^HKB@t4g4a~=Kb8;W+YUlJY`QcYm6khbsqF`wq2-Ub}LH+ku{WU(8lB~TC87m z%?x8!@%=kc9rYtp^DBpZZ;g1mDD_SL;X}75iezV4j+Ai2>y~;o7$8I?rCRq@g&h{ zH1~`lLVE2IzK!@;9a{%B3oML)>hyj^Ej7wbpdBR)j?S>;;&<+ZRIZ z>DWSB0m>H4q2K&&2X9bLunOp&DbpinV}E_T2~F=^pD_mYloEkt!QFOwl<_hN!X>Nm#l*nqw2#A6-D_D#pm6^gyY2(-f5YqDzsy^O1j<`Kl6xDiMxkbCvHpx6H+$MfC*!!}c4#q*LqU$DJ zBSeKBR7gtvT*10ztbf@t#Xgz5td! zzL94}`hL&g+zwsDU`t!Owe*S72P^gy?B1319K%|~N~u|mgr3suAavw~3aTX(CTG>F zR=VJk6pJ!tq$Q4ao(OxB^5^j@O+vIJnmM#$iAT|OA3^2R*y{3VO{N9?blRCOK}KRb z^tu|P-RjX(qoSG{Q_4CHMC^+137<045Yvq3Uui+i-2LbxyYLC)rm#C2yYtaGPxp%; zp7gyAwb1R>Hilc(A~Q*pMt#=$O>b}$jQ6wCluTX4^J16LcrvO;>?VRSEwe-gDo-VS zeZsa$gv%r>hvyvml%ZlZkj4a!ygW+^@I!4Tw5aT~&odVbo@Cx&u@t8g5zkj;TbAZ2#$I@? zE%UkdiwlzLff}NvSK|lESS1-^^_N|OxD*%qR`>ffl!{n-t4Y-#H`ij(FyxG0$b?3J zN_B&DHBA*r%;J{xNk${-!ESJCg?FXYp$}}yRS?L~EY2%4Yt`^W#n&{5d7||m)VBQi z*V;1%;W<=CeTlwR@9-_A5F+E%s1gy3?rkh22f=Y!c=Eb}WM61e+DX$L8Oy0v*$ZCJ zOq~mETh(S^2Au-*?ZZ_PARKWDGx?SyubI8z8(NVxcg07LxD}?Y#%X{Ew(@54M@x+sPWFRdU}!S$vNZc z*++Kc@D@u?ix%$6l+CrLHH5_rAGi7g0hbx_MoXTy7#ithOQy-x`xhUvPundr4s!`l zdPN=}26wmoQK#TGSs(vprPid4QlB?ay7}Zvvt|Z#Br{fKcI`4fe8G=q=9_S2?){Q! z6pq%Y-D%URRw+;--4M<=`>en9xSLEeGSq*S)mtgi>w_IMTvQ5N&!iS-yWfOxSGr|p zai*rHDiUxg(mS23@|`clygVM@lG^L-NG;TPiRrSlMeK5vMGDfRI?kE|%HV1YJf(U@u^_9xWvbAG*ZV*gBcD+`tTOP; zdvHqjA^E};un%w?u4D6*9loss>>?uy3Jm?;H>NNEnxq=VH)urO}{cw zh35|xz1Rd?*hDfSG}(~k>TC6YRi~?*Cx_!;DNnhu(yZ4P13V$NQ(N3+PrA(V)8e<_ z3XE9!`aL;$Ve$bT3-|KCH8`*)+vvq%pM4^=eY2Tr8IYjdZWU~E=J=6kg|0e+r%c%Y z)Yy}PUAWgiJL4$2jtaBGc$XANkh@;SGnN(&qHa@BtnZiL*3HBNeFiZcn;qKio}A~s zGqc~K((?0>`Ta5_O?EB76~!B*X~^>a@l0JxPl?dOMEBHde{q$x_P=A_l(-o-u*Mm_Bor7ymawmH{E*9z6CYH3{%`25q z9*>~&LX>SXpg}h$;r?~SGNGLFt-W+nnY3vn;o1r-0Ikg*rs-xJH`wC`7Y(-G%D{30 zrqk7UetC7VOI1}N3C25jOiyrp&V2xFTkUR@<5XD6h>Y2V{LZ`C5zk8F9`T|05-k;R z87@R0y%3Yr!m{tLWDvxognAO^??SFPlNa6h>7TDKq1>&!V`X=g>bYIWkO##mYd*J8 zoTebB-&8Pc``+`*IfS*0|~B6iVC;&hiU7qN@NQG=1G-o7SSS3%^2Hi_dnSp?<1fC}R+&lb5UDS@sk=zlt- zjU_!*iPW_UUL5k`58%CdP>F3jC+oRAj&S~QY zZ7-4X6~PrjI_atIG=18G@_x4DmWMF**>jir=D|gY*(CaPgda$?l&BRB?WqI z<5E=X(^xot-9^EcHWOG5R6@s7ZXG)ihP})}?SMFO)enm>iu7D~(U4VyX_i z?lC!qg&w?DBUeLue{)n&hBGj5?dqQ36r!i8JcGn69;koAn&AKrTG&Mh-y+v!1(@Sq z34TW6uBMg>Hytz1ysuYLAAeo06}P)+uq~;WO;{IM*)ha9;+d5N?wl0dnHV_IJVY;{z!SBq1e9He8p zQ|F>9 ztlIq3A(xwiORg4I?SSkr`($@?tC!wp7%W(4k|+!=%e!26fkU9Wuxr`EzzMFA5n*eL zBh-$(lIZm?@hyI`Y*ADz_SZSaObzyei`4q#RHcp9mB7^;e zcc%(4A_|xK(+(vO+cS>6{5IHUC5ukJ$y!U^BwkNOo2ccJVqy2QkR;@rj5 zD7WjT(F$72dT6RRQM$5nq{%MwzVt70&V}*xe z(++*yYtZ#yFF<=8LtJ*b7pbBT&aO#@PGp0-hi%$*@}6|sd2f^^Psy~$%wqj2Vd;24 z$KdG!6H<2XYmE<45F*-@oKRxQVeA(d2`On}yD_ZK&0#ztO>j0FB$@IL2(K*Zmkz8I zW3Z-}9DP!CG9yfTMVfeB@P>Cu`faB!uzvP%vAov!!KNC(AA}2<_z8yJlrf;WO&ph3 zkR*B{EZn13+Fz&U%<~N3MMB8v($;O z+aE8R#Ba`bB}3gv)l25O5kw}lS`pl{*98t2c8F-e*yqNYO6;w^Ut9%_-fX7c-q%&9 zL+yhSEM26>T>hMPK4L7(S>Bs;G1c^C7~Ocirik?>{Kidqve?Pn8EttqvLsnX5}DSh zXVZRw?fdONdD36VDu&vR;QQ@LyabwDZ%FRLrVsI)6tVcS_h4<42pY`iQ0Oad74*<} z-_#t;np|gg@_U{+7f5K@J8rPlBG>XzmD2$c)h-8hxMYaAX$#jP+_L4o0iF{MKj&vO^2X!O6`z9Xh{k9{Eh-Q@Y z{&weK6bmQX5d)u_w7Oq+lE zDuu_)na)maN%Q*8(zI|RMO^>0G^u-D9x+Z@b&$dIqLb1(Jgop8FL|Iq5oTk?4u z5gqW&@8>rXDsMUE3VE6AHIc=DMJZ%c#O>4p@vtiR(zu0ihb)m*5h=u zAupPTsqH1aQOJWWBc)z_$0DV%svYu$&AQ6$g$rnV|59Q}IKr~xbSS4RWPM4wvZerz zaxyQO>p5_rAm~C)UakSzXV%)Vt+BA~*q2H}ff8qVc`+L>2aR9g?1lu-bA=C{R~e<{ z3|5G{>MV=ri~zlTj7M)56@5_`;^@o4X*f`MCNxP7C#rcWd~g29O3t#AO&0;F(*f?# zbaKKEY${UjK7^n0Bbl@XHJf=1?0CFz`(8|o)ccn!d{x2ELG&ZMCx{{V;7rk!-iru) zC^pJ90;Ch_e0XJ7@TkQ`nI~_~0)t6LU zPiu1Op6JMOu|O?lP4!RBWSAZukT7ErIXObX4M7DvJDioPDnL8OIz!`bE|aelKKQlJ z=4_B7wq8-)PV9qG*D0sHFK1?M^*dJV3syX0>@Cy7ajGIe>WyK?l>ThtcjzvAu%(?QTmW`@!VtAQln)7o`) z*dq_+G`$F?!Fqdi+1vT0rY1_*B!jYvKVu@3ZRFErhiT6F_W zt46~1)*+EqLs1|b`4+}&>0c>VNjTtXE2snFk;!GV5yUv9>xI0O{4vqt)AO>ypDj!V zjFTHMNev0w@5+U{nw($wuLy!e1RJSr(CN2`x1mf`fc3I`b1wOqUbwNbm>7vhan!817nz~y@^Wg4;_M&A0W+Z=6k9=bVJdC9PC7uW-y9oUJ3JroeY~qb5JnAf z*0MH%xVs#t3{JWDu!xuwz=bplFJ#llpGC-dv>_E-e5#9NLhXIHiYstV8#-~iVATNr z{A<;c7U$39Um)JS^s;ACS7wfNSx#$izguT~dNALL-)v`tAAO??Tps6Au_wPi+PXBz z9o5+(-9O{|C&lKPZ!3A}^ag(ka`Nfdzl^go<2I&7GQ!H^+6y9LgBI^kIk8t1M*XFH z)1~rsTwGm?OV9#ivZTPpC?kE_T@P=%lyRjUaFap^9xz-f#OyOM(sb#uZO$1Vkuq9b zpM85xF?wm3A0WR;lfIr3Bl0I+Qq2bmJ;#Ys0fo8c5jK@ z(ziQjO5||7SJEry)nrAp2c&BES`4b21{y=V-TmF9{!e>f9uD>T#%(WI>W~znO+uDz z#bj$al`Uo8N0McX!kCFMwn`@@Wz9}@Gxlc6*d@v~!jNG&62nX=3?aPFoO61Ar~1AB zz3+9s?|aoBuB$)3^L?KCdq4McZ=cUCY;CETKXU-tP&7Ii7E5SE)Kmzm5T9OK&%?Xf zrZsF64IvqbG4`2ih_o-lhT=UildO|zITPvH&t5reO_?%QY4VZA05?N$L68pDhceM0 z+iFv-V-#yRV?dxm)EsLX4KQ=J`;38A(bM7!@UQ-GNiTkyx5olS=$yK$XzBd)3+!XJ zOHXfbo^eXTSI78c@j?bsBX_zLho=@=^6X`KugH|z#247CeJ<0TpWU__HpvPoPKa7+ zB)z;b8taeCt}7G%xLB7DJWk`cmExB^I225u;gC<&DIpb*U^4BIg|qg>9*#C5hLJw~ zyx1siLMb@2B8sAq{EudnJX@2-1Wt9h$PeS`?}|sb5-6;4{?IbTyI2aArr$mF+5P6p zVmMh68+;%Byq{(hzu8Xkkb67;fKR z?s}q&thI3GcYb@RNY>E~&uJ`4fLOhw2B`h%8Xs4_+}LeFH3js#+=p&RK9xHCK9KJVU{6x;9~RhM{hTSjI+tN9O!Ke5s9Eh#3@L5}iO z^y;?DH=(6x^rlYVQ$b&R1)02bPif&X{z$2UoM-eK;q1;%S@$a9C+TH-?rra1PI2^T>Q)SGzkCknyFc%PZ5&G_TB zb41|;|MW#nMaiTFXh`Pw!t8(xkCB5hCe@W!`riP%BDl)jbLGv$qj$%&VAEJ?(aAD8 zUoo#pT}gU=AvYl^!o*9gFj+`COBmitpvulN)6e&$k3B$X1AU7x?!RpjDwobTLOdO|tTIacnpleb?nyRree|dYf)+i?|_C zR`bcM%TQuYw0}j+G5R@%n}p1rR(^^%?kV~G0O)2}Ds}F$bO~=!HC*(Nx+X&@ciA>{ z_*usGW%bqIz?!I0061y-?6jt$DWG7-^w;9`db46XJnchw!Y^_sBllLT$23&X=xcec zSBtH#Vs@rb$Zd01jWI5wkeL$)7}l8-oy7LCz!JcJX*qMh5AoZpJZd3bbvStOx?l2f zzNcxlYtAZWq9QN90M+(+E1~_V%G#&B7nZg4Hy^Zg_bs0^II7?ss?3#yzX7^bkWTKb;d@ z%735gZx#D#TT$|^93$Psm_*S4FE`L2wMD{LBK^-dPfX%m54F4D6&MS-a}7B&=ju-D zYI$6!Pfs%)ybxxBJ3Xhx_R!eO9dsHSo9Q17R&mdGBk0T2^zTUzPJf>=(xR?&+lo#6 z0(5Y+>FhkS3M9{lB3Ga88OoGSxiapYej4%zd0{bCd9c(2v1B9S;_%6oFC?+fFr7gC zblWPL%qVhd@+o^fM}56q{LJBQhJ%QSSgV@Gpt)w+SBj3osKUu4d=bRYZOd?!SM``i zVx8V2{6j};39L>J@4Xf?Y;#%coy9lywI*tH5vVlt_Zmhm49JtDPwM#1XY03K8IfYk zKS3;sm(Jr}?WIFrJKuB(TO{5EVU=6ulJ@*AY)ARBp%mkTw2S$=Nbmqcit+w_nkf=E z%g5ec@ZQa-j!~R>Ip5+Yy%#s9zKee9v57bH`V8Y}KTyJ+@%km#`GXk==45n!YlwvC z;2pdtbr97tbP+%QuJ|!mg6++t60V_iby|if$uVMiB@O8?` zTgPIQYU@BE&uT3#_?kpD^8Ii}QOLqWg_o^&TgRO<=nJ>kl5U(VaBB<{JI0#%!He6* zUSSYv+nx<4p6aie!sQ*hCD?RyML69E)`ea*5urTx+|k!%VznpkakPM~m{B-w#j4uY6jN zHJPpsn#Lf6(wQqD8mP#VM->TmKRh4Lzl1it(TuDhHo-BmvrZCfFm@!wXWRC=t7Scu zom#s)A-gZ@9`U&b>G)*eSS@7Tzf;Mf_OgGH#D_b4hXx=~`{xu@wf0wm{>8xN{vg`Q zTaj}qCc7mIrg9ExXlIgv$xX|=Ez{JGFikyv)^6XB>lX4GT=}&+9?kC^I6_< zpRIf+Wn)oOt+q)>!MUEM*|&hmC7Y=9Vh$yW)%T)s@$3#!QJ7tDI|`u}^#Tapj2m9d z_u}{}%k)t;uI@8Zu?_LCB}#)<|Il~Tl(-|?l#pr_GxX}I%Dm-Rt$S~(PiN)1$=d8# zN-^n*H@dvpY%(e4^OWzjrH{MuIF^aGDM0FC^MrRR9I-F`O}db|q2T~BZGD}PiltWO zNAynJwA9>ae>rIvwOPBEw&aIW=FU$KWf-T>%{kR8%8T(v~C;AaHi*>)t zyfk=nwRpcd@HJaPu*1A54<&j}(kklGHsjDIA1jV2?cI)0q;zZmP`K42 zkOh#l>D7PCb6Gkkj_qD?cGjpm@k>Ch@fc7I!3V5Doa)LXrKX!-2(e}IVM?c!xp`K13vfA=S zYwC^GIcuP~cJ8x%Pk4KMY<5ig} zeO64P2~TA2vW1G}S`qHPT4b@@(PyQXPbn)UsJw0ae9=tb_1xU0nKWLQPd?Y9IydcJ ze5CKkyt!*#w&fXK%c!D!Qah#3YUXQ6RCD%sD~c-%dE*T+1eD882-g&p^bgU)qj@kFDdU*+%5{sGXIyzjbyCS5ZfKXKkUz zpL;jXuOSRmJwr;Gqm5I{?|6(nE3NB()^9ap9LBWECChzoY!VgD|-Y1tBSG z4B+}f9s}Duy|>L3uJ+F33i+#&Q1jFB#`n`AdfUufBupTed$lBN%*4NeHf%9ja;bGW z!sm@Y^Da9i2UB00lT_5CXAw9vQlJ+AGlaDWxEuIqqrCEf^8CqC9x%Oc^&&q){S1m~ zAmfC1gss-Ku*DYX9ygZlO_nWZA`yv421)y?JR zI?AF%d<}!q+vnb#`3H~r=R!O`Uc)(G7a)~i?0N-o^)GfqwXW*Dkt-H$(RZAIxV`$g z`5cwsdD+5EJhWcPhl`7UpBxyqBKI42s$2F6F8xb8xzGIes@cF*`CgJ*VY9o@)1X~wX(^?TskMb--?-@2gO7s}4F8~Xj9BOFJ! z9_8X!ZeRny_pd*Cd#(x~piHO`zxoePu>Kp##>c_O{{0iIA28*y;gqn}e!_KH;Kvt1 zL&FXzic)pV~8|TE{qA)g?t*plJ55iSXTt@3Ka0$=>g4gGiZA{QHw&Br<8#!; zVGG0a6Yc2Vpc9UkIP3BPre=IV>h;ig&DSU5(Qk&2+7Z0;X>R4MAcz`GM-y1O_q}9H zyC`B?%O*{8(jX`e))=5<7U%(9@Hk|WXp!GGiTAH5MVB5ag`-!;wAWW+A|3?RVtz zsqnX)_0`!tyO_k=yIBn{c@D9rId!98%{n4Sqr`btTkh;*u0UpAWUqj24+R0-M<V&nq5L$Mn@k*ySs zA&yPj-37>#>#7@H^dw0ORo*d;)N0g4ZmaBrelvFk5`uk>f4{*t8*@(f{=JcLdiy2x zH9tW%Z>6*Jq^~4T>`_jv+}PX42hSw~;7Mr}P>y^;SFT^tMs!G1B*GwKr#>x123S-k8`RTpt}lc1+X?Q*sP(5xUt!(k@wba{%u&h@8mfgU?X#aFpGt zHz!eG9GFKbmf1mTX(a)T)7bq0%No9Qfdh9tzbXSgTb0jDTe>q3vtM z`1El84=V=7ZWFezL5q{!lK8MCO+DTlE5_E*rx{yW-okCc#A|YT3^P34Oto@av^QC} zS$|0d%TV=c^x@%xG;?NoA1=L=2{2S9U%vv}$FqUGy1_x0IB~P8D*G3d@3Bg>~EF<#V;?#4n6nPdHaI449u&}Ccp~1zvaMPsF!@=*DxO> z$WYHA*;;bEgX z9l}K$YwbIkkwTMMS8)(X-=*4+yeK*D8Q)!0=dE}!&HMfysQ=vc#USkL>!r>>hxOcg zFL9ikQzzm&^U+djsd`Cz<$BZD6>e+Q^&aT*+Qn^lKfZqVk%a~G4Lc1pZ&$|*ist-E2F^M&Yqsh0%Wzs^_%@Q4@IB`kA zTQk?zMX$7H|DY6lnlzvjw#{tV^r$$$hu2n9zY^_B)_^f?jo9J_3nfzn6JBlY`i zgnr)CLG>L_g)5^*agtUW7j)FM_5A6C{ z*2Bk>1j~raC>Hld&?W##K|SxgIL5a6ehSte@LCi-P<`+&g+>GzR^|h4U_jfFQu`Fk zk~nY(;jX3i1?>HN)r&u=T-;{q-@^S$3R=%?NWH5*o6NP-Dd_WAbSGd?dE3qgBc*Gr zrqd8$@4pAzC`Ze@-m}by4EP8*Tf9m&eLuW@%41DkLxb4>ts_nZ68Ys~b$!f};>;E9 zrjzNR?!z=k43pf+8aLw@>(||79vY9%m|kUXh!y~y2sHZ8%;gmjaC7j#7C?tJRx}i;}n;hx_EE=B}bRuyB5H(;67oI8tvaUkf z{9Nlpi4^)3!`iOqlgQ`Wu0e)#*4nEqsV2mrxjqe&^zFUt+;z3+WfLm8F622x*6l22 zbvoZ=`-;;P8v_7J(@Ep zh)QBdj)z$Y-1HPRJC#Y&-3+3a2lY>_IPpn9i|wm?>3eaveOM$(e(#&_O~zi-@sNUm z)_okirDJa`t>e=A#vT?x``t>{AE!e!SK9A=@1JLOWCQ2ytY8`aCg5zMbY~B}jWGq+ zw>!;blmqu*_%tEe|7GGT7MU*&b-l{xpuN2a5)62-Ookj$hbPOCXQb_m^vUiw)TTG) z-x)1G_ZqBNQr-k^kr#y_bX5|-4L=ULgQb#9Dp(-t-r4=jaW_i5vr=Unh+uQcth+j8 zb5*ij+oO)#dL!I+YyEeEHtnhczFk7um+2M*Ydo-!*V+>lIwM8o6ZW1NZ2@d86ul#1 zJuhY=F;(>sk_0EE2BP8>kSmTUU=SW59&zkkIBxi~)`o8|*=ry=q9DL{Lz{{c*YLXG zj^1u0=J{QuFmV(Y#)jPz3oU3giy3^qyJ|C-@ls`EVb=} zWpe(t^zU8czxHR!Wxd0C>wgKBR9=fsHP9Om?fza4e}@h`*>`d6;`lGY5`P7cR#~7j z?|0wnn;_4Ia);gdd40Q&+`|36#b4X{^|k&T?T7tR!v7qX%wK2gKf3YDWcb{T&My#z*ujE7mEn$X;O;OM zS$XiGVoxw!{K^^$q_Wfj5yQwkK^zOgJy?rIEi~~dsj;db-hjHMe6HG)Tr>$-XWW-& zsY8xyfQz-X3SgOA9IKHtlu1Ci>X8m6qD}(rFl!c+D44Y+^}kZg zZmuli$xarLmObL_+0Fz7yX5?gU}A0b@LGg`_@qU4l=yLz+5y13H5xnupye7fHANu* zT?I-hZWPjaS|1<-QY((||A1z}zTpn6VpVW-UlF>VN%>l}rb!*3tzE5C*|Op{o3%#l zQ}9b*%+nsM2F=a@yzuqsq5#xC3YP@j2}=tLH%rBUuz`}LmEl`w-yP`qQI483 zAdpTz{Ww9%X(Zwduhv6AMhEi!@|~d0M%`EeB+4gc8w_^)f)tI0d)op?TfwC2LB2Sz zb`G>CCW8V_K4um$5y1qdbcO+Qxvo2lIy`;n%?ZkC5{pF_4d2in>i|0pxEvb)D7OwZ zyADVsX6k9R*8ze!R_oC$D2ptu3oA;ewy)EeyzeI?Wy2gt5X(HVhK0EQ{^EkXLp|yI;M09#sJgE&f|3&(?X*sUHvH$5T|PTcZubOOxW4y?fK%b z`yS=+FZOkjkC$go~x zi(ARw&={u=h9~066y~g?oa%!yYa>;Oh&X>v9M_fU8$|GLN`6!*qN!NZR+|@ep}t$w1dGD15Z87K1}8S~_MZ;!XyGiWSL&&zS?q z<;Dxr>$P*kW|J!Tk4S`T^Z5}as^c#BeT{$FkiQZmXG8JUTRm^65Z4v$KK3}r&lY%x zGQo88^hL?+w9|=>fgVGRszj#3!J&R)mO)e5SIy;PfpEfl6puBNY4_Y)dwTV@7H_L( zgC%FG*JJV8CBOv?hs1#`xWZhPKi@vdi>?s4v8c|QAXGr6&C_mQpFS#%g3mljmThQ5 zzVum$56p`g962$-CQp)nkfL^u#VY@-RoFA{zW1w0kzKMOmyUhQ#{8YhQ3eU1`w{!X zjHqHKrh|&SL*s9_ELKs%)TRe!g}M&yFx(oaiX}F_0i(_RE+<}=nD!MDC==X!qFYBJ zQT2&uXzvUM_8`?Nz`Ks!^7y`@G$-}Y_N7;EA1hYe)|vJ)z`&NtFG%N4oUjY^XB0p0 z@~E2>gZe{y>0i1~b-g-?&8BISD(uvd(KZL((k$*K7$J(&wu{ zRXhLA8S3d_no!&ae8AU zwfZA;`cAbU@_;~UdYTiglJX^-*IIi*{cY(%p~SKS)YIw>PcDYWcNGQ(TZ|};ooh<* z(r!oAdGAp3sJ0b=3Ec#0nK~HvFz@X`Qi5DV+aBkDll2BqNJ(nG_4rEJfro1kz6Oqg zoVWzms?_^*3*riSrESHYI)hYG+@US zhhA-`1aabSORg-i%w-Rr$)7LAMb5lBj933O4b-ip#3#!rn5uj?={jHx2X!QbW$sH{ zHMIod`vgt5-%RCX^y+I+6f77Jv(Wf}H$hQIed#G|{ljJX9AHyAqUe)aQI-!B0eTm*XB)DR;?$aUn!_l&98 zZLxGrB#CKP%Sm`qSBgOOqg-sNQwBWR-!mLC6JscvcISL@KNUZYyBOpfHFtWf zEzQ7B_^wxq{Id>RH-M0i2GVCpIiwZL0Ocb*6!AyDK;hxd~kZ*3W(39aI6Dgl=t z&!H9W#;@p8Pj2h1eC6_@vSHEh0tmyoe&7;tm?_=%P*SDdn%#0lH|sJ-f!Fqq{<_qe z_%)IXqrW%>iSc|)Bf4G9y@gGOtDA*daF%(Ac8skgm3G)|q?UPrVusnkr05|(IKd%r z7w@x4VdzBWx_#_&=gFMj_5%}2a{qGT{|as**J9%jt%3P6rpI#g` zEmt&`*oFQ2-kFdGs=<+c%10W{pQ@yUnQxc5_yvD`v@Vtdms%drmWKw+)B2XnUbt7= z$~>*(G7Icp-o6nVoOI0p{D3*J%#&uR-CR2)aD0{@H*Cm(opqDm-4QmQoe`^ChMo!H zz;zgUW-uzwzU7!ulKpXYZQ%oYTl!D(C74P(w@s}WRhXx2`+i<@MoM|KX$JC z)l(E+iQfOZRC-c*X?9n`S1sqRE?YXf{WYO!*sP0baFeWU)&BEyZQ-DMC}Fc8a9zrR zu;I!FC2Ut@*8ZF}*RzQMX5(usO;_hfDS`C=wBO$w`1ySQztOn#|7kzguWfzHp#0y^ zJ=dTt@T(^E&j\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\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.\n shapeA.r *= smoothstep(0.12, 0.26, shapeA.r);\n shapeB.r *= smoothstep(0.12, 0.26, shapeB.r);\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 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, 2)\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 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 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 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 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\n\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..17dc2d0 --- /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\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.\n shapeA.r *= smoothstep(0.12, 0.26, shapeA.r);\n shapeB.r *= smoothstep(0.12, 0.26, shapeB.r);\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 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, 2)\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 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 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 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 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/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/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/iso-lattice/iso-lattice-demo.tsx b/registry/iso-lattice/iso-lattice-demo.tsx new file mode 100644 index 0000000..a9173e7 --- /dev/null +++ b/registry/iso-lattice/iso-lattice-demo.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useLayoutEffect, useMemo } from "react" + +import { + ComponentControls, + ControlColor, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { useHydratedTheme } from "@/hooks/use-hydrated-theme" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { + DARK_COLOR, + DEFAULT_GLOW, + DEFAULT_INTERVAL, + DEFAULT_SIZE, + DEFAULT_SPACING, + DEFAULT_SPEED, + IsoLattice, + LIGHT_COLOR, + SHAPES, + type IsoLatticeShape, +} from "@/registry/iso-lattice/iso-lattice" + +function norm(hex: string) { + return hex.trim().toUpperCase() +} + +function isStockColor(color: string) { + return norm(color) === norm(LIGHT_COLOR) || norm(color) === norm(DARK_COLOR) +} + +const SHAPE_LABELS: Record = { + cube: ["Cube", "One solid block"], + steps: ["Steps", "Four stairs climbing back"], + pillars: ["Pillars", "A three-by-three bar chart"], + coil: ["Coil", "A spring rising off the floor"], +} + +export function IsoLatticeDemo() { + const theme = useHydratedTheme() + const stock = theme === "dark" ? DARK_COLOR : LIGHT_COLOR + + const defaults = useMemo( + () => ({ + color: stock, + cube: true, + steps: true, + pillars: true, + coil: true, + interval: DEFAULT_INTERVAL, + size: DEFAULT_SIZE, + spacing: DEFAULT_SPACING, + glow: DEFAULT_GLOW, + speed: DEFAULT_SPEED, + floor: true, + interactive: true, + }), + [stock] + ) + + const { props, updateProp, resetProps, hasChanges, setProps } = + usePreviewProps(defaults) + + // Keep the stock amber on the active theme until the user picks a color. + useLayoutEffect(() => { + setProps((prev) => { + if (!isStockColor(prev.color)) return prev + if (norm(prev.color) === norm(stock)) return prev + return { ...prev, color: stock } + }) + }, [setProps, stock]) + + const useAutoColor = isStockColor(props.color) + const { cube, steps, pillars, coil } = props + const shapes = useMemo(() => { + const on = { cube, steps, pillars, coil } + const picked = SHAPES.filter((shape) => on[shape]) + return picked.length ? picked : SHAPES + }, [cube, steps, pillars, coil]) + const allShapes = shapes.length === SHAPES.length + + return ( + <> + +
+ +
+
+ + + updateProp("color", v)} + /> + {SHAPES.map((shape) => ( + updateProp(shape, v)} + /> + ))} + `${v}s`} + onChange={(v) => updateProp("interval", v)} + /> + updateProp("size", v)} + /> + `${v}px`} + onChange={(v) => updateProp("spacing", v)} + /> + updateProp("glow", v)} + /> + updateProp("speed", v)} + /> + updateProp("floor", v)} + /> + updateProp("interactive", v)} + /> + + + ) +} diff --git a/registry/iso-lattice/iso-lattice-vanilla.ts b/registry/iso-lattice/iso-lattice-vanilla.ts new file mode 100644 index 0000000..4e8c216 --- /dev/null +++ b/registry/iso-lattice/iso-lattice-vanilla.ts @@ -0,0 +1,921 @@ +export type IsoLatticeTheme = "light" | "dark" | "auto" + +/** The figures the lattice can light up, all drawn in isometric. */ +export type IsoLatticeShape = "cube" | "steps" | "pillars" | "coil" + +export type IsoLatticeOptions = { + /** + * Figures to cycle through, in order. One figure holds still. + * Default `["cube", "steps", "pillars", "coil"]`. + */ + shapes?: IsoLatticeShape[] + /** Seconds each figure holds before the next one builds. Default `4`. */ + interval?: number + /** Tile color, any CSS hex. Omit to follow the theme. */ + color?: string + /** Figure size as a share of the shorter side, 0.2–1. Default `0.6`. */ + size?: number + /** Distance between lattice tiles in CSS px, 4–24. Default `8`. */ + spacing?: number + /** Halo behind the figure, 0–1. `0` turns it off. Default `0.6`. */ + glow?: number + /** Speed of the glint and the shimmer. Default `1`. */ + speed?: number + /** A faint isometric floor grid under the figure. Default `true`. */ + floor?: boolean + /** The pointer lights the lattice like a lamp. Default `true`. */ + interactive?: boolean + /** + * Palette mode. Default `auto` follows shadcn / next-themes + * (`html.dark` class) so light and dark swap with the site theme. + */ + theme?: IsoLatticeTheme + /** Fires whenever resolved dark mode changes. */ + onThemeChange?: (dark: boolean) => void +} + +export type IsoLatticeInstance = { + setOptions: (options: Partial) => void + destroy: () => void +} + +/** Warm amber that glows on a dark page. */ +export const DARK_COLOR = "#FFB25B" +/** A burnt orange that holds its contrast on paper. */ +export const LIGHT_COLOR = "#C2560C" + +export const SHAPES: IsoLatticeShape[] = ["cube", "steps", "pillars", "coil"] +export const DEFAULT_INTERVAL = 4 +export const DEFAULT_SIZE = 0.6 +export const DEFAULT_SPACING = 8 +export const DEFAULT_GLOW = 0.6 +export const DEFAULT_SPEED = 1 + +/** How long one figure takes to build over the last, in seconds. */ +const MORPH_SECONDS = 1.6 +/** Each figure is drawn once into a square mask this many pixels wide. */ +const MASK = 512 +/** Half the width of the mask in figure units; a figure's radius is 1. */ +const EXTENT = 1.6 +const COS30 = Math.cos(Math.PI / 6) + +const VERT = ` +attribute vec2 a_position; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); +} +` + +const FRAG = ` +precision highp float; + +uniform vec2 u_resolution; +uniform vec2 u_center; +uniform float u_radius; +uniform float u_spacing; +uniform float u_pixel; +// Red is the figure, green its blurred halo, blue the floor grid. +uniform sampler2D u_maskA; +uniform sampler2D u_maskB; +uniform float u_progress; +uniform float u_time; +uniform float u_sweep; +uniform float u_glow; +uniform float u_floor; +uniform vec2 u_mouse; +uniform float u_pointer; +uniform float u_dark; +uniform vec3 u_color; +uniform vec3 u_deep; +uniform vec3 u_hot; + +const float SQ3 = 1.7320508; +const float EXTENT = ${EXTENT.toFixed(2)}; + +// Dave Hoskins' hash: no sin(), so it stays stable on mobile GPUs. +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 toUv(vec2 q) { + return vec2(q.x, -q.y) / (2.0 * EXTENT) + 0.5; +} + +vec2 sampleShape(vec2 q, float k) { + vec2 uv = toUv(q); + return mix(texture2D(u_maskA, uv).rg, texture2D(u_maskB, uv).rg, k); +} + +void main() { + vec2 frag = gl_FragCoord.xy; + + // Hex lattice in vertical columns, so its rows run at 30 degrees, the + // same angles as an isometric edge. Each pixel finds its nearest cell. + vec2 p = frag - 0.5 * u_resolution; + vec2 period = vec2(SQ3, 1.0) * u_spacing; + vec2 a = mod(p, period) - 0.5 * period; + vec2 b = mod(p - 0.5 * period, period) - 0.5 * period; + vec2 off = dot(a, a) < dot(b, b) ? a : b; + vec2 cell = p - off; + vec2 id = floor(cell / (0.5 * period) + 0.5); + + vec2 cellPx = cell + 0.5 * u_resolution; + vec2 q = (cellPx - u_center) / u_radius; + + // The next figure builds from the floor up: each cell turns over when the + // build front passes its height, give or take a little. + float rise = clamp((q.y + 1.3) / 2.6, 0.0, 1.0); + float delay = 0.62 * rise + 0.08 * hash(id); + float k = smoothstep(0.0, 1.0, clamp((u_progress - delay) / 0.3, 0.0, 1.0)); + vec3 shapeA = texture2D(u_maskA, toUv(q)).rgb; + vec3 shapeB = texture2D(u_maskB, toUv(q)).rgb; + // The mask's soft outline would leave half-strength specks along every + // edge; a cell is either on the figure or off it. + shapeA.r *= smoothstep(0.12, 0.26, shapeA.r); + shapeB.r *= smoothstep(0.12, 0.26, shapeB.r); + float m = mix(shapeA.r, shapeB.r, k); + // The build front burns brighter as it passes. + float flare = 4.0 * k * (1.0 - k) * max(shapeA.r, shapeB.r); + + // A level band of light rises through the figure, then rests. + float glint = exp(-pow((q.y - u_sweep) / 0.14, 2.0)); + float shimmer = 0.93 + 0.07 * sin(u_time * (1.1 + 1.6 * hash(id + 7.1)) + 6.2832 * hash(id + 3.7)); + + // The pointer is a lamp: it raises the bare lattice and the figure under it. + vec2 lamp = (cellPx - u_mouse) / (150.0 * u_pixel); + float lit = exp(-dot(lamp, lamp)) * u_pointer; + + // Size follows only what the figure is, so tiles never pulse in and out; + // the shimmer, the glint and the build front change light, not size. + float floorLevel = shapeA.b * u_floor; + float body = max(m, floorLevel) + lit * (0.14 + 0.3 * m); + float level = max(m * shimmer * (1.0 + 0.6 * glint) + 0.7 * flare, floorLevel); + level += lit * (0.14 + 0.45 * m); + + // Each cell is an isometric diamond; at full strength neighbours meet, so + // lit faces read as solid tiles and dim ones break into a dotted grain. + float fill = clamp(body, 0.0, 1.0); + float w = u_spacing * 0.866 * mix(0.2, 0.84, sqrt(fill)); + float n = abs(off.x) + abs(off.y) * SQ3; + float dotA = 1.0 - smoothstep(w - 0.9, w + 0.9, n); + dotA *= smoothstep(0.015, 0.08, body); + dotA *= mix(0.35, 1.0, smoothstep(0.0, 0.7, clamp(level, 0.0, 1.0))); + vec3 dotC = mix(u_deep, u_color, smoothstep(0.0, 0.8, level)); + dotC = mix(dotC, u_hot, smoothstep(0.85, 1.6, level)); + + // Halo: the blurred figure where this pixel sits, plus a broad wash. + vec2 qp = (frag - u_center) / u_radius; + float halo = sampleShape(qp, u_progress).g; + float wash = exp(-dot(qp, qp) * 0.8); + float glowA = u_glow * (halo * mix(0.18, 0.5, u_dark) + wash * mix(0.04, 0.16, u_dark)); + // Interleaved gradient noise, half a step, so the dark falloff never bands. + float ign = fract(52.9829189 * fract(dot(frag, vec2(0.06711056, 0.00583715)))); + glowA = clamp(glowA + (ign - 0.5) / 255.0, 0.0, 1.0); + + // Dot over halo, premultiplied for a transparent canvas. + float outA = dotA + glowA * (1.0 - dotA); + vec3 outC = dotC * dotA + u_color * glowA * (1.0 - dotA); + gl_FragColor = vec4(outC, outA); +} +` + +type Ctx = CanvasRenderingContext2D +type Point = [number, number] + +/** Isometric projection: x runs down-right, z down-left, y straight up. */ +function iso(x: number, y: number, z: number): Point { + return [(x - z) * COS30, y - (x + z) * 0.5] +} + +function gray(level: number) { + const v = Math.round(clamp(level, 0, 1) * 255) + return `rgb(${v},${v},${v})` +} + +function polyline(ctx: Ctx, points: Point[], close = false) { + ctx.beginPath() + points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))) + if (close) ctx.closePath() +} + +function stroke(ctx: Ctx, points: Point[], level: number, width: number) { + polyline(ctx, points) + ctx.strokeStyle = gray(level) + ctx.lineWidth = width + ctx.stroke() +} + +function fill(ctx: Ctx, points: Point[], level: number) { + polyline(ctx, points, true) + ctx.fillStyle = gray(level) + ctx.fill() +} + +/** Every figure stands on this floor. */ +const FLOOR = -0.4 + +/** + * A solid box lit from above: bright top, half-lit left, shaded right, with + * the near edges picked out. Draw far boxes first; nearer ones cover them. + */ +function box(ctx: Ctx, [x0, x1]: Point, [y0, y1]: Point, [z0, z1]: Point) { + ctx.globalCompositeOperation = "source-over" + // Butt caps: a round cap pokes past the corner as a stray tile. + ctx.lineCap = "butt" + ctx.lineJoin = "round" + fill( + ctx, + [iso(x1, y1, z1), iso(x0, y1, z1), iso(x0, y0, z1), iso(x1, y0, z1)], + 0.6 + ) + fill( + ctx, + [iso(x1, y1, z1), iso(x1, y1, z0), iso(x1, y0, z0), iso(x1, y0, z1)], + 0.42 + ) + fill( + ctx, + [iso(x1, y1, z1), iso(x1, y1, z0), iso(x0, y1, z0), iso(x0, y1, z1)], + 0.78 + ) + stroke(ctx, [iso(x0, y1, z1), iso(x1, y1, z1), iso(x1, y1, z0)], 1, 0.035) + stroke(ctx, [iso(x1, y1, z1), iso(x1, y0, z1)], 0.7, 0.035) +} + +/** One solid cube, a unit tall. */ +function drawCube(ctx: Ctx) { + box(ctx, [-0.5, 0.5], [FLOOR, FLOOR + 1], [-0.5, 0.5]) +} + +/** Four steps climbing away from the viewer. */ +function drawSteps(ctx: Ctx) { + for (let i = 3; i >= 0; i--) { + const near = 0.5 - i * 0.25 + box(ctx, [-0.5, 0.5], [FLOOR, FLOOR + (i + 1) * 0.25], [near - 0.25, near]) + } +} + +/** A three-by-three bar chart; heights are fixed so it reads as data. */ +function drawPillars(ctx: Ctx) { + const heights = [ + [0.35, 0.6, 1], + [0.55, 0.85, 0.45], + [0.9, 0.3, 0.65], + ] + const cells: Array<[number, number, number]> = [] + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + cells.push([(i - 1) * 0.34, (j - 1) * 0.34, heights[i]![j]!]) + } + } + // Painter's order: the far corner of the grid first. + cells.sort((a, b) => a[0] + a[1] - (b[0] + b[1])) + const half = 0.13 + for (const [x, z, height] of cells) { + box( + ctx, + [x - half, x + half], + [FLOOR, FLOOR + height], + [z - half, z + half] + ) + } +} + +/** A coil rising off the floor; the near side of each turn is the bright one. */ +function drawCoil(ctx: Ctx) { + ctx.globalCompositeOperation = "source-over" + ctx.lineCap = "round" + const turns = 3.5 + const steps = 280 + const radius = 0.5 + const points: Array<[Point, number]> = [] + for (let i = 0; i <= steps; i++) { + const t = (i / steps) * turns * Math.PI * 2 + const x = radius * Math.cos(t) + const z = radius * Math.sin(t) + const y = FLOOR + 0.06 + (i / steps) * 0.92 + points.push([iso(x, y, z), x + z]) + } + polyline(ctx, ellipse(radius + 0.1, FLOOR), true) + ctx.strokeStyle = gray(0.22) + ctx.lineWidth = 0.04 + ctx.stroke() + // Far half first, then the near half over it. + for (const near of [false, true]) { + for (let i = 1; i < points.length; i++) { + const [a, depth] = points[i - 1]! + const [b] = points[i]! + if (depth > 0 !== near) continue + stroke(ctx, [a, b], near ? 1 : 0.38, 0.085) + } + } +} + +/** A flat circle on the floor plane, as a projected outline. */ +function ellipse(radius: number, y: number): Point[] { + const out: Point[] = [] + for (let i = 0; i < 96; i++) { + const t = (i / 96) * Math.PI * 2 + out.push(iso(radius * Math.cos(t), y, radius * Math.sin(t))) + } + return out +} + +/** A faint floor tile with a coarse grid, the same under every figure. */ +function drawFloor(ctx: Ctx) { + const r = 0.8 + ctx.globalCompositeOperation = "source-over" + fill( + ctx, + [ + iso(r, FLOOR, r), + iso(r, FLOOR, -r), + iso(-r, FLOOR, -r), + iso(-r, FLOOR, r), + ], + 0.05 + ) + for (let i = -2; i <= 2; i++) { + const v = (i / 2) * r + // Wide enough that every grid line lands on a full row of cells. + stroke(ctx, [iso(v, FLOOR, -r), iso(v, FLOOR, r)], 0.15, 0.07) + stroke(ctx, [iso(-r, FLOOR, v), iso(r, FLOOR, v)], 0.15, 0.07) + } +} + +const DRAW: Record void> = { + cube: drawCube, + steps: drawSteps, + pillars: drawPillars, + coil: drawCoil, +} + +/** Box-blurs by halving and growing back, where `ctx.filter` is missing. */ +function softBlur(source: HTMLCanvasElement, into: Ctx) { + let current = source + for (let size = MASK / 2; size >= MASK / 16; size /= 2) { + const next = document.createElement("canvas") + next.width = next.height = size + const ctx = next.getContext("2d")! + ctx.imageSmoothingQuality = "high" + ctx.drawImage(current, 0, 0, size, size) + current = next + } + into.imageSmoothingQuality = "high" + into.drawImage(current, 0, 0, MASK, MASK) +} + +// Figures don't depend on color or theme, so every instance shares them. +const maskCache = new Map() + +/** A black square canvas set up in figure units: y up, origin centered. */ +function figureCanvas(draw: (ctx: Ctx) => void) { + const canvas = document.createElement("canvas") + canvas.width = canvas.height = MASK + const ctx = canvas.getContext("2d", { willReadFrequently: true })! + ctx.fillStyle = "#000" + ctx.fillRect(0, 0, MASK, MASK) + const unit = MASK / (2 * EXTENT) + ctx.setTransform(unit, 0, 0, -unit, MASK / 2, MASK / 2) + draw(ctx) + ctx.setTransform(1, 0, 0, 1, 0, 0) + return { canvas, ctx } +} + +let floorPixels: Uint8ClampedArray | null = null + +/** + * Draws a figure once: red is the crisp figure, green a soft halo, blue the + * floor grid (the same in every mask, so it never changes in a build). + */ +function renderMask(shape: IsoLatticeShape): Uint8Array { + const cached = maskCache.get(shape) + if (cached) return cached + + const { canvas: sharp, ctx } = figureCanvas(DRAW[shape]) + floorPixels ??= figureCanvas(drawFloor).ctx.getImageData( + 0, + 0, + MASK, + MASK + ).data + + const soft = document.createElement("canvas") + soft.width = soft.height = MASK + const sctx = soft.getContext("2d", { willReadFrequently: true })! + sctx.fillStyle = "#000" + sctx.fillRect(0, 0, MASK, MASK) + if (typeof sctx.filter === "string") { + sctx.filter = `blur(${MASK / 36}px)` + sctx.drawImage(sharp, 0, 0) + } else { + softBlur(sharp, sctx) + } + + const a = ctx.getImageData(0, 0, MASK, MASK).data + const b = sctx.getImageData(0, 0, MASK, MASK).data + const out = new Uint8Array(MASK * MASK * 4) + for (let i = 0; i < out.length; i += 4) { + out[i] = a[i]! + out[i + 1] = b[i]! + out[i + 2] = floorPixels[i]! + out[i + 3] = 255 + } + maskCache.set(shape, out) + return out +} + +function isDev() { + return ( + typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + ) +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function numberOr(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, min, max) +} + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "").trim() + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h.padEnd(6, "0").slice(0, 6) + const n = Number.parseInt(full, 16) + if (Number.isNaN(n)) return [1, 0.7, 0.36] + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] +} + +function mixRgb( + a: [number, number, number], + b: [number, number, number], + t: number +): [number, number, number] { + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} + +function shapesOf(value: unknown): IsoLatticeShape[] { + if (!Array.isArray(value)) return SHAPES + const valid = value.filter((s): s is IsoLatticeShape => s in DRAW) + return valid.length ? valid : SHAPES +} + +/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: IsoLatticeTheme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +function compile(gl: WebGLRenderingContext, type: number, source: string) { + const shader = gl.createShader(type) + if (!shader) return null + gl.shaderSource(shader, source) + gl.compileShader(shader) + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + if (isDev()) { + console.warn( + "IsoLattice: shader failed to compile\n", + gl.getShaderInfoLog(shader) + ) + } + gl.deleteShader(shader) + return null + } + return shader +} + +/** + * A lattice of isometric tiles that lights up solid figures (a cube, a + * staircase, a bar chart, a coil) on a faint floor grid, and builds each + * one over the last from the floor up. A level glint rises through the + * figure, and the pointer lights the lattice like a lamp. Transparent, theme-aware; pauses + * off-screen and in hidden tabs; holds the first figure still under + * `prefers-reduced-motion`. Recovers from a lost WebGL context. + */ +export function createIsoLattice( + canvas: HTMLCanvasElement, + initial: IsoLatticeOptions = {} +): IsoLatticeInstance | null { + let options: IsoLatticeOptions = { + floor: true, + interactive: true, + theme: "auto", + ...initial, + } + + const mouse = { x: 0, y: 0 } + const target = { x: 0, y: 0 } + // The lamp eases in and out, so leaving the canvas never snaps it off. + let lamp = 0 + let lampTarget = 0 + let dark = resolveDark(options.theme ?? "auto") + options.onThemeChange?.(dark) + + const gl = canvas.getContext("webgl", { + alpha: true, + premultipliedAlpha: true, + antialias: false, + depth: false, + stencil: false, + powerPreference: "high-performance", + }) + if (!gl) return null + + type Loc = WebGLUniformLocation | null + let vs: WebGLShader | null = null + let fs: WebGLShader | null = null + let program: WebGLProgram | null = null + let buf: WebGLBuffer | null = null + let textures = new Map() + const u: Record = {} + + const texture = (shape: IsoLatticeShape) => { + const existing = textures.get(shape) + if (existing) return existing + const tex = gl.createTexture()! + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + MASK, + MASK, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + renderMask(shape) + ) + textures.set(shape, tex) + return tex + } + + // Builds every GL resource. Runs at startup and again when the browser + // restores a lost context, since the old objects die with it. + const initGl = () => { + vs = compile(gl, gl.VERTEX_SHADER, VERT) + fs = compile(gl, gl.FRAGMENT_SHADER, FRAG) + if (!vs || !fs) { + if (vs) gl.deleteShader(vs) + if (fs) gl.deleteShader(fs) + return false + } + + program = gl.createProgram() + if (!program) { + gl.deleteShader(vs) + gl.deleteShader(fs) + return false + } + gl.attachShader(program, vs) + gl.attachShader(program, fs) + gl.linkProgram(program) + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + if (isDev()) { + console.warn( + "IsoLattice: program failed to link\n", + gl.getProgramInfoLog(program) + ) + } + gl.deleteProgram(program) + gl.deleteShader(vs) + gl.deleteShader(fs) + return false + } + gl.useProgram(program) + + buf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), + gl.STATIC_DRAW + ) + const loc = gl.getAttribLocation(program, "a_position") + gl.enableVertexAttribArray(loc) + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) + gl.viewport(0, 0, canvas.width, canvas.height) + + const linked = program + for (const name of [ + "resolution", + "center", + "radius", + "spacing", + "pixel", + "maskA", + "maskB", + "progress", + "time", + "sweep", + "glow", + "floor", + "mouse", + "pointer", + "dark", + "color", + "deep", + "hot", + ]) { + u[name] = gl.getUniformLocation(linked, `u_${name}`) + } + gl.uniform1i(u.maskA!, 0) + gl.uniform1i(u.maskB!, 1) + textures = new Map() + return true + } + if (!initGl()) return null + + let pixel = 1 + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + const w = parent.clientWidth + const h = parent.clientHeight + if (w <= 0 || h <= 0) return + pixel = Math.min(window.devicePixelRatio || 1, 2) + canvas.width = Math.max(1, Math.floor(w * pixel)) + canvas.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() + } + + let colorKey = "" + const syncColor = () => { + const hex = options.color || (dark ? DARK_COLOR : LIGHT_COLOR) + const key = `${hex}|${dark}` + if (key === colorKey) return + colorKey = key + const base = hexToRgb(hex) + const white: [number, number, number] = [1, 1, 1] + const black: [number, number, number] = [0, 0, 0] + // Dim dots sink toward the page; hot dots move away from it. + const deep = mixRgb(base, dark ? black : white, 0.55) + const hot = mixRgb(base, dark ? white : black, dark ? 0.62 : 0.5) + gl.uniform3fv(u.color!, base) + gl.uniform3fv(u.deep!, deep) + gl.uniform3fv(u.hot!, hot) + gl.uniform1f(u.dark!, dark ? 1 : 0) + } + + // Which figure is showing, and how far the morph to the next one has got. + let shapes = shapesOf(options.shapes) + let index = 0 + let held = 0 + let progress = 0 + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + let onScreen = true + let raf = 0 + let destroyed = false + let lost = false + let last = 0 + let clock = 0 + + const moving = () => + onScreen && !mqReduce.matches && document.visibilityState !== "hidden" + + function wake() { + // An observer callback can still arrive after destroy. + if (raf || destroyed || lost) return + last = performance.now() + raf = requestAnimationFrame(tick) + } + + const advance = (dt: number) => { + if (shapes.length < 2) { + progress = 0 + return + } + if ( + progress > 0 || + held >= numberOr(options.interval, DEFAULT_INTERVAL, 0.5, 60) + ) { + progress += dt / MORPH_SECONDS + if (progress >= 1) { + index = (index + 1) % shapes.length + progress = 0 + held = 0 + } + } else { + held += dt + } + } + + // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside. + const tick = (now: number) => { + raf = 0 + if (lost) return + const dt = Math.min(0.1, Math.max(0, (now - last) / 1000)) + last = now + const live = moving() + const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4) + if (live) { + clock += dt * speed + advance(dt) + } + // Under reduced motion the first figure holds, with no lamp. + const still = mqReduce.matches + if (still) { + index = 0 + progress = 0 + } + + const ease = 1 - Math.exp(-dt * 6) + mouse.x += (target.x - mouse.x) * ease + mouse.y += (target.y - mouse.y) * ease + lamp += (lampTarget - lamp) * (1 - Math.exp(-dt * 3)) + + syncColor() + const w = canvas.width + const h = canvas.height + const radius = + (numberOr(options.size, DEFAULT_SIZE, 0.2, 1) * Math.min(w, h)) / 2 + const pointer = options.interactive === false || still ? 0 : lamp + // The figure stays put: any drift would slide it across the lattice and + // make the tiles along its edges blink. + const cx = w / 2 + const cy = h / 2 + + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, texture(shapes[index % shapes.length]!)) + gl.activeTexture(gl.TEXTURE1) + gl.bindTexture(gl.TEXTURE_2D, texture(shapes[(index + 1) % shapes.length]!)) + + gl.uniform2f(u.resolution!, w, h) + gl.uniform2f(u.center!, cx, cy) + gl.uniform1f(u.radius!, radius) + gl.uniform1f( + u.spacing!, + numberOr(options.spacing, DEFAULT_SPACING, 4, 24) * pixel + ) + gl.uniform1f(u.pixel!, pixel) + gl.uniform1f(u.progress!, progress) + gl.uniform1f(u.time!, clock) + // The glint rises through the figure, then rests above it a while. + gl.uniform1f(u.sweep!, still ? -9 : -1.6 + 4.2 * ((clock / 4.2) % 1)) + gl.uniform1f(u.glow!, numberOr(options.glow, DEFAULT_GLOW, 0, 1)) + gl.uniform1f(u.floor!, options.floor === false ? 0 : 1) + gl.uniform2f(u.mouse!, mouse.x, mouse.y) + gl.uniform1f(u.pointer!, pointer) + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) + gl.drawArrays(gl.TRIANGLES, 0, 6) + + const settling = Math.abs(lampTarget - lamp) > 0.002 + if (live || (settling && !still)) raf = requestAnimationFrame(tick) + } + + // 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) => { + e.preventDefault() + lost = true + cancelAnimationFrame(raf) + raf = 0 + vs = fs = program = buf = null + textures = new Map() + } + const onRestored = () => { + if (destroyed || !initGl()) return + lost = false + // Uniforms reset with the context, so the colors go up again. + colorKey = "" + wake() + } + canvas.addEventListener("webglcontextlost", onLost) + canvas.addEventListener("webglcontextrestored", onRestored) + + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + const io = new IntersectionObserver(([entry]) => { + onScreen = entry?.isIntersecting ?? true + wake() + }) + io.observe(canvas) + const onVisibility = () => wake() + document.addEventListener("visibilitychange", onVisibility) + const onReduce = () => wake() + mqReduce.addEventListener("change", onReduce) + + const syncTheme = () => { + const next = resolveDark(options.theme ?? "auto") + if (next === dark) return + dark = next + options.onThemeChange?.(dark) + wake() + } + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + + const onMove = (e: PointerEvent) => { + if (options.interactive === false) return + const rect = canvas.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + const x = (e.clientX - rect.left) / rect.width + const y = 1 - (e.clientY - rect.top) / rect.height + const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1 + if (inside) { + // The first touch puts the lamp where the pointer is, not at a corner. + if (lamp < 0.01) { + mouse.x = x * canvas.width + mouse.y = y * canvas.height + } + target.x = x * canvas.width + target.y = y * canvas.height + } + const next = inside ? 1 : 0 + if (next !== lampTarget) { + lampTarget = next + wake() + } + } + const onLeave = () => { + lampTarget = 0 + wake() + } + window.addEventListener("pointermove", onMove, { passive: true }) + document.documentElement.addEventListener("pointerleave", onLeave) + + resize() + wake() + + return { + setOptions(next) { + options = { ...options, ...next } + const nextShapes = shapesOf(options.shapes) + if (nextShapes.join() !== shapes.join()) { + shapes = nextShapes + index = 0 + held = 0 + progress = 0 + } + syncTheme() + wake() + }, + destroy() { + destroyed = true + cancelAnimationFrame(raf) + raf = 0 + ro.disconnect() + io.disconnect() + mo.disconnect() + document.removeEventListener("visibilitychange", onVisibility) + mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) + window.removeEventListener("pointermove", onMove) + document.documentElement.removeEventListener("pointerleave", onLeave) + canvas.removeEventListener("webglcontextlost", onLost) + canvas.removeEventListener("webglcontextrestored", onRestored) + for (const tex of textures.values()) gl.deleteTexture(tex) + textures.clear() + gl.deleteProgram(program) + gl.deleteShader(vs) + gl.deleteShader(fs) + gl.deleteBuffer(buf) + // Free the GPU context once the canvas has left the page; browsers cap + // live contexts at about 16. A canvas still in the page (a React + // Strict Mode replay) keeps it, so a new engine can reuse it. + const lose = gl.getExtension("WEBGL_lose_context") + queueMicrotask(() => { + if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext() + }) + }, + } +} diff --git a/registry/iso-lattice/iso-lattice.svelte b/registry/iso-lattice/iso-lattice.svelte new file mode 100644 index 0000000..3cd2262 --- /dev/null +++ b/registry/iso-lattice/iso-lattice.svelte @@ -0,0 +1,85 @@ + + + + + diff --git a/registry/iso-lattice/iso-lattice.tsx b/registry/iso-lattice/iso-lattice.tsx new file mode 100644 index 0000000..26fa832 --- /dev/null +++ b/registry/iso-lattice/iso-lattice.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useEffect, useRef } from "react" + +import { cn } from "@/lib/utils" + +import { + createIsoLattice, + DEFAULT_GLOW, + DEFAULT_INTERVAL, + DEFAULT_SIZE, + DEFAULT_SPACING, + DEFAULT_SPEED, + SHAPES, + type IsoLatticeInstance, + type IsoLatticeOptions, +} from "./iso-lattice-vanilla" + +export { + DARK_COLOR, + DEFAULT_GLOW, + DEFAULT_INTERVAL, + DEFAULT_SIZE, + DEFAULT_SPACING, + DEFAULT_SPEED, + LIGHT_COLOR, + SHAPES, +} from "./iso-lattice-vanilla" +export type { + IsoLatticeInstance, + IsoLatticeOptions, + IsoLatticeShape, + IsoLatticeTheme, +} from "./iso-lattice-vanilla" + +export type IsoLatticeProps = Omit & { + className?: string +} + +/** + * A lattice of glowing isometric tiles that builds solid figures, one over + * the last, from the floor up. Transparent; theme-aware. + */ +export function IsoLattice({ + className, + shapes = SHAPES, + interval = DEFAULT_INTERVAL, + color, + size = DEFAULT_SIZE, + spacing = DEFAULT_SPACING, + glow = DEFAULT_GLOW, + speed = DEFAULT_SPEED, + floor = true, + interactive = true, + theme = "auto", +}: IsoLatticeProps) { + const canvasRef = useRef(null) + const instanceRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + instanceRef.current = createIsoLattice(canvas, { + shapes, + interval, + color, + size, + spacing, + glow, + speed, + floor, + interactive, + theme, + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null + } + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + instanceRef.current?.setOptions({ + shapes, + interval, + color, + size, + spacing, + glow, + speed, + floor, + interactive, + theme, + }) + }, [ + shapes, + interval, + color, + size, + spacing, + glow, + speed, + floor, + interactive, + theme, + ]) + + return ( +
+ +
+ ) +} diff --git a/registry/iso-lattice/registry.json b/registry/iso-lattice/registry.json new file mode 100644 index 0000000..a2f8971 --- /dev/null +++ b/registry/iso-lattice/registry.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "iso-lattice", + "type": "registry:ui", + "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": "iso-lattice.tsx", + "type": "registry:ui", + "target": "components/ui/iso-lattice.tsx" + } + ] + }, + { + "name": "iso-lattice-svelte", + "type": "registry:ui", + "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": "iso-lattice.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/iso-lattice.svelte" + } + ] + } + ] +} diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index b236ae7..ab9e2f0 100644 --- a/skills/23rd/SKILL.md +++ b/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/skills/23rd/apis.md b/skills/23rd/apis.md index 1943e4d..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 diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 14ae94d..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. 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. From b4d991191775da75062a3bcac95a996b0572dd41 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 16:51:19 +0530 Subject: [PATCH 08/19] Keep Iso Lattice tiles inside the figure's silhouette. A cell counted as lit when its centre landed on the figure, so the column straddling each vertical edge drew whole diamonds past the outline at half strength, with a stray tile at each side corner. Now a cell lights only when all four tips of a full-size tile land on the figure, so edges end on a clean column of whole tiles. Co-Authored-By: Claude Opus 5.5 --- public/r/iso-lattice-svelte.json | 2 +- public/r/iso-lattice.json | 2 +- registry/iso-lattice/iso-lattice-vanilla.ts | 20 +++++++++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/public/r/iso-lattice-svelte.json b/public/r/iso-lattice-svelte.json index e2b65eb..933cb6c 100644 --- a/public/r/iso-lattice-svelte.json +++ b/public/r/iso-lattice-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/iso-lattice/iso-lattice.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/iso-lattice.svelte" } diff --git a/public/r/iso-lattice.json b/public/r/iso-lattice.json index 17dc2d0..b79e993 100644 --- a/public/r/iso-lattice.json +++ b/public/r/iso-lattice.json @@ -6,7 +6,7 @@ "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\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.\n shapeA.r *= smoothstep(0.12, 0.26, shapeA.r);\n shapeB.r *= smoothstep(0.12, 0.26, shapeB.r);\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 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, 2)\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 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 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 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 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", + "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 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, 2)\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 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 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 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 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" } diff --git a/registry/iso-lattice/iso-lattice-vanilla.ts b/registry/iso-lattice/iso-lattice-vanilla.ts index 4e8c216..694883e 100644 --- a/registry/iso-lattice/iso-lattice-vanilla.ts +++ b/registry/iso-lattice/iso-lattice-vanilla.ts @@ -108,6 +108,18 @@ vec2 sampleShape(vec2 q, float k) { return mix(texture2D(u_maskA, uv).rg, texture2D(u_maskB, uv).rg, k); } +// Whether a whole tile fits on the figure: its four tips, pulled in a touch, +// must all land on it. A cell whose centre is inside but whose tips are not +// would poke past the silhouette as a ragged half-lit column. +float fits(sampler2D mask, vec2 q, vec2 tip) { + float c = 1.0; + c = min(c, texture2D(mask, toUv(q + vec2(tip.x, 0.0))).r); + c = min(c, texture2D(mask, toUv(q - vec2(tip.x, 0.0))).r); + c = min(c, texture2D(mask, toUv(q + vec2(0.0, tip.y))).r); + c = min(c, texture2D(mask, toUv(q - vec2(0.0, tip.y))).r); + return step(0.08, c); +} + void main() { vec2 frag = gl_FragCoord.xy; @@ -132,9 +144,11 @@ void main() { vec3 shapeA = texture2D(u_maskA, toUv(q)).rgb; vec3 shapeB = texture2D(u_maskB, toUv(q)).rgb; // The mask's soft outline would leave half-strength specks along every - // edge; a cell is either on the figure or off it. - shapeA.r *= smoothstep(0.12, 0.26, shapeA.r); - shapeB.r *= smoothstep(0.12, 0.26, shapeB.r); + // edge; a cell is either on the figure or off it, and only when the whole + // tile (at full size, so the call never depends on brightness) fits. + vec2 tip = vec2(1.0, 1.0 / SQ3) * u_spacing * 0.866 * 0.84 * 0.85 / u_radius; + shapeA.r *= smoothstep(0.12, 0.26, shapeA.r) * fits(u_maskA, q, tip); + shapeB.r *= smoothstep(0.12, 0.26, shapeB.r) * fits(u_maskB, q, tip); float m = mix(shapeA.r, shapeB.r, k); // The build front burns brighter as it passes. float flare = 4.0 * k * (1.0 - k) * max(shapeA.r, shapeB.r); From f01ceaec35270d1ab15217585d5858b3b4543437 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 16:51:19 +0530 Subject: [PATCH 09/19] Cap Ring Tower at five rings and let each ring be set on its own. `rings` now runs 1 to 5, default 5; the stage shows about three. It also takes an array, one object per ring from the bottom up, setting that ring's rotation, tilt, spin, crawl speed, parallax or images. Anything left out follows the tower, so `{}` is a plain ring. Rings that share an image list share one strip, and poses are read live, so sliders never rebuild the bands. The loop now rests when nothing moves. The demo gains a ring picker with sliders for the picked ring, and the docs a table of ring fields. Co-Authored-By: Claude Opus 5.5 --- content/docs/components/ring-tower.mdx | 44 +++- public/r/ring-tower-svelte.json | 2 +- public/r/ring-tower.json | 2 +- registry/ring-tower/ring-tower-demo.tsx | 112 ++++++++- registry/ring-tower/ring-tower-vanilla.ts | 266 ++++++++++++++++------ registry/ring-tower/ring-tower.tsx | 11 +- 6 files changed, 357 insertions(+), 80 deletions(-) diff --git a/content/docs/components/ring-tower.mdx b/content/docs/components/ring-tower.mdx index fdfc069..3cced8d 100644 --- a/content/docs/components/ring-tower.mdx +++ b/content/docs/components/ring-tower.mdx @@ -5,8 +5,6 @@ description: A tower of tilted bands wrapped in your images. Drag to spin it; th import { RingTowerDemo } from "@/components/demos" -## Preview - ## Installation @@ -75,12 +73,52 @@ Every band in step, like the original: +### Each ring + +Pass `rings` an array instead of a number to set each ring on its own, bottom ring first. The array's length is the ring count, up to 5. Anything a ring leaves out follows the tower, so `{}` is a plain ring. + + + +```tsx + +``` + + +```svelte + +``` + + + +| Ring field | Type | Default | +| --- | --- | --- | +| `rotation` | `number` | `i * 90`, degrees; which way the ring leans | +| `tilt` | `number` | `14`, degrees, −45–45 | +| `spin` | `number` | `0`, degrees per second, −180–180; sweeps the lean around | +| `speed` | `number` | the tower's `speed`, −4–4; negative crawls backwards | +| `parallax` | `number` | the tower's `parallax`, alternating direction; degrees, −90–90 | +| `images` | `string[]` | the tower's `images` | + ## Props | Prop | Type | Default | | --- | --- | --- | | `images` | `string[]` | none, draws a grid band | -| `rings` | `number` | `12`, 1–24 | +| `rings` | `number \| RingTowerRing[]` | `5`, 1–5, or one object per ring | | `speed` | `number` | `1`, 0–4 crawl speed | | `draggable` | `boolean` | `true` | | `parallax` | `number` | `24`, degrees, 0–90 | diff --git a/public/r/ring-tower-svelte.json b/public/r/ring-tower-svelte.json index 75d72f9..6ca6741 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..9f9011e 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 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 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 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 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/registry/ring-tower/ring-tower-demo.tsx b/registry/ring-tower/ring-tower-demo.tsx index 799a16d..5381129 100644 --- a/registry/ring-tower/ring-tower-demo.tsx +++ b/registry/ring-tower/ring-tower-demo.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo } from "react" +import { useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { @@ -15,7 +15,13 @@ import { DEFAULT_PARALLAX, DEFAULT_RINGS, DEFAULT_SPEED, + DEFAULT_SPIN, + DEFAULT_TILT, + MAX_RINGS, + defaultParallax, + defaultRotation, RingTower, + type RingTowerRing, } from "@/registry/ring-tower/ring-tower" const W = 624 @@ -121,6 +127,15 @@ const CARDS = [ type Source = "photos" | "cards" | "none" +type RingKey = Exclude + +const NO_OVERRIDES: RingTowerRing[] = Array.from( + { length: MAX_RINGS }, + () => ({}) +) + +const degrees = (v: number) => `${v}°` + const SOURCES: { id: Source; label: string; images?: string[] }[] = [ { id: "photos", label: "Photos", images: PHOTOS }, { id: "cards", label: "Cards", images: CARDS }, @@ -136,14 +151,39 @@ export function RingTowerDemo() { backface: DEFAULT_BACKFACE, draggable: true, source: "photos" as Source, + // Per-ring overrides, bottom ring first; `{}` follows the tower. + overrides: NO_OVERRIDES, }), [] ) const { props, updateProp, resetProps, hasChanges } = usePreviewProps(defaults) + const [picked, setPicked] = useState(Math.floor(DEFAULT_RINGS / 2)) const images = SOURCES.find((s) => s.id === props.source)?.images + const ring = Math.min(picked, props.rings - 1) + const override = props.overrides[ring] ?? {} + + // Only the rings someone changed need the array form. + const rings = useMemo(() => { + const list = props.overrides.slice(0, props.rings) + return list.some((r) => Object.keys(r).length) ? list : props.rings + }, [props.overrides, props.rings]) + + const setRing = (key: RingKey, value: number) => { + const next = props.overrides.slice() + next[ring] = { ...next[ring], [key]: value } + updateProp("overrides", next) + } + + const ringValue: Record = { + rotation: override.rotation ?? defaultRotation(ring), + tilt: override.tilt ?? DEFAULT_TILT, + spin: override.spin ?? DEFAULT_SPIN, + speed: override.speed ?? props.speed, + parallax: override.parallax ?? defaultParallax(ring, props.parallax), + } return ( <> @@ -151,10 +191,10 @@ export function RingTowerDemo() { title="Ring Tower" stageClassName="min-h-0 overflow-hidden p-0" > -
+
updateProp("rings", v)} /> @@ -244,6 +284,68 @@ export function RingTowerDemo() { step={0.01} onChange={(v) => updateProp("backface", v)} /> +
+ Ring +
+ {Array.from({ length: props.rings }, (_, i) => ( + + ))} +
+
+ 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,36 +388,66 @@ 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()) + }) } - void Promise.allSettled(urls.map(loadImage)).then((results) => { - if (token !== loadToken || destroyed) return - const loaded = results.flatMap((r) => - r.status === "fulfilled" ? [r.value] : [] + const nextLayout = rings.map(keyFor).join("\0") + if (nextLayout !== layoutKey) { + layoutKey = nextLayout + buildBands() + } + } + + // 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 } @@ -357,7 +468,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 +491,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 +521,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 +540,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 || @@ -506,7 +636,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 +667,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 +697,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, From f537c4708467ab992b54ae1a6c2f49e015de2a30 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 16:51:19 +0530 Subject: [PATCH 10/19] Put component previews on a full-height stage with the props beside it. On a component page the live preview now fills a stage at the top of the docs, edge to edge, with the controls in a panel beside it from `xl` and below it on narrower screens, so props can be tuned while watching. The page follows on a sheet underneath with the outline alongside. Demos size themselves with `--preview-h`, the `## Preview` headings go, and the copied JSX prints object props as object literals. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/conventions.md | 2 + app/docs/[[...slug]]/page.tsx | 128 +++++++----- app/globals.css | 32 +++ components/component-controls.tsx | 195 ++++++++++++++---- components/component-preview.tsx | 74 ++++++- components/docs-shell.tsx | 84 ++++++-- components/docs-stage.tsx | 54 +++++ components/docs-toc.tsx | 21 +- content/docs/components/ascii-fluid.mdx | 2 - content/docs/components/ascii-logo.mdx | 2 - content/docs/components/dithered-404.mdx | 2 - content/docs/components/folio.mdx | 2 - .../docs/components/gooey-color-picker.mdx | 2 - content/docs/components/iso-lattice.mdx | 2 - content/docs/components/live-orb.mdx | 2 - content/docs/components/logo-burst.mdx | 2 - content/docs/components/phosphor-score.mdx | 2 - content/docs/components/radiant-lines.mdx | 2 - content/docs/components/shader-anime-fire.mdx | 2 - content/docs/components/shader-gradient.mdx | 2 - content/docs/components/shader-metal.mdx | 2 - content/docs/components/shader-sky.mdx | 2 - content/docs/components/stretchy-footer.mdx | 2 - content/docs/components/typewriter.mdx | 2 - registry/ascii-fluid/ascii-fluid-demo.tsx | 2 +- registry/ascii-logo/ascii-logo-demo.tsx | 2 +- registry/dithered-404/dithered-404-demo.tsx | 2 +- registry/folio/folio-demo.tsx | 2 +- registry/iso-lattice/iso-lattice-demo.tsx | 2 +- registry/live-orb/live-orb-demo.tsx | 2 +- registry/logo-burst/logo-burst-demo.tsx | 2 +- .../phosphor-score/phosphor-score-demo.tsx | 2 +- registry/radiant-lines/radiant-lines-demo.tsx | 8 +- .../shader-anime-fire-demo.tsx | 2 +- .../shader-gradient/shader-gradient-demo.tsx | 2 +- registry/shader-metal/shader-metal-demo.tsx | 2 +- registry/shader-sky/shader-sky-demo.tsx | 2 +- .../stretchy-footer/stretchy-footer-demo.tsx | 2 +- registry/typewriter/typewriter-demo.tsx | 2 +- skills/23rd/conventions.md | 2 + 40 files changed, 496 insertions(+), 164 deletions(-) create mode 100644 components/docs-stage.tsx 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/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..3e7518b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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/docs-shell.tsx b/components/docs-shell.tsx index 2b1ae25..5349eb3 100644 --- a/components/docs-shell.tsx +++ b/components/docs-shell.tsx @@ -13,6 +13,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,8 +66,11 @@ function SidebarBody({ }) { return ( <> - - + +
+ + +
@@ -109,6 +113,62 @@ function SidebarBody({ ) } +/** + * From `md` up the page tree sits at the left. Its toggle (or Ctrl+B) + * folds it into a slim rail that keeps only the toggle, in the same spot, + * so a component's stage can take the width. + */ +function DesktopSidebar({ + tree, + githubStars, +}: { + tree: RootNode + githubStars: number | null +}) { + const { open } = useSidebar() + + return ( + + ) +} + /** Below `md` the page tree lives in a sheet opened from the top bar. */ function MobileSidebar({ tree, @@ -145,25 +205,11 @@ 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. - */} - +
+
diff --git a/components/docs-stage.tsx b/components/docs-stage.tsx new file mode 100644 index 0000000..0058858 --- /dev/null +++ b/components/docs-stage.tsx @@ -0,0 +1,54 @@ +"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 ) : 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 9907153..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 607bce1..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 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 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, 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 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 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", + "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/ring-tower-svelte.json b/public/r/ring-tower-svelte.json index 6ca6741..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 9f9011e..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\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 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 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 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 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", + "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/ascii-fluid/ascii-fluid-vanilla.ts b/registry/ascii-fluid/ascii-fluid-vanilla.ts index e2ff798..7c65b89 100644 --- a/registry/ascii-fluid/ascii-fluid-vanilla.ts +++ b/registry/ascii-fluid/ascii-fluid-vanilla.ts @@ -595,24 +595,42 @@ export function createAsciiFluid( raf = requestAnimationFrame(tick) } + /** + * Draws now: resizing cleared the canvas, and waiting for the next frame + * would show it blank for one. Zero dt, so time doesn't jump; the tick + * reschedules itself if it should keep running. + */ + const redraw = () => { + 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-vanilla.ts b/registry/ascii-logo/ascii-logo-vanilla.ts index 5917ac7..2ccd1e2 100644 --- a/registry/ascii-logo/ascii-logo-vanilla.ts +++ b/registry/ascii-logo/ascii-logo-vanilla.ts @@ -805,6 +805,9 @@ export function createAsciiLogo( 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) diff --git a/registry/dithered-404/dithered-404-vanilla.ts b/registry/dithered-404/dithered-404-vanilla.ts index 6e94de9..b712c5c 100644 --- a/registry/dithered-404/dithered-404-vanilla.ts +++ b/registry/dithered-404/dithered-404-vanilla.ts @@ -584,15 +584,21 @@ export function createDithered404( reformAt = now } + /** Returns whether the backing size changed (which clears the canvas). */ const resize = () => { 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/iso-lattice/iso-lattice-vanilla.ts b/registry/iso-lattice/iso-lattice-vanilla.ts index 694883e..ae6c972 100644 --- a/registry/iso-lattice/iso-lattice-vanilla.ts +++ b/registry/iso-lattice/iso-lattice-vanilla.ts @@ -670,19 +670,28 @@ export function createIsoLattice( if (!initGl()) return null let pixel = 1 + /** + * Sizes the canvas to its parent; true when the backing store changed. + * Assigning the same size still clears the canvas, so it only assigns on + * a real change. + */ const resize = () => { 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, 2) - 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)) + 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` gl.viewport(0, 0, canvas.width, canvas.height) wake() + return true } let colorKey = "" @@ -727,6 +736,15 @@ export function createIsoLattice( 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) + } + const advance = (dt: number) => { if (shapes.length < 2) { progress = 0 @@ -831,7 +849,10 @@ export function createIsoLattice( canvas.addEventListener("webglcontextlost", onLost) canvas.addEventListener("webglcontextrestored", onRestored) - 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/live-orb/live-orb-vanilla.ts b/registry/live-orb/live-orb-vanilla.ts index 4b7e4c2..6941c38 100644 --- a/registry/live-orb/live-orb-vanilla.ts +++ b/registry/live-orb/live-orb-vanilla.ts @@ -415,20 +415,23 @@ export function createLiveOrb( // When the pointer last moved, from performance.now(). let pointerAt = -Infinity + /** 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 gl.viewport(0, 0, canvas.width, canvas.height) - // Resizing clears the canvas, so a still frame needs a redraw. - wake() + return true } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") @@ -442,6 +445,19 @@ export function createLiveOrb( raf = requestAnimationFrame(tick) } + /** + * Draws now: resizing cleared the canvas, and waiting for the next frame + * would show it blank for one. Zero dt, so time doesn't jump; the tick + * reschedules itself if it should keep running. + */ + function redraw() { + if (destroyed || lost) return + cancelAnimationFrame(raf) + raf = 0 + last = performance.now() + tick(last) + } + const onMove = (e: PointerEvent) => { if (!options.interactive) return const parent = canvas.parentElement @@ -528,7 +544,9 @@ export function createLiveOrb( if (live && !destroyed) raf = requestAnimationFrame(tick) } - 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/logo-burst/logo-burst-vanilla.ts b/registry/logo-burst/logo-burst-vanilla.ts index a59d56c..2160719 100644 --- a/registry/logo-burst/logo-burst-vanilla.ts +++ b/registry/logo-burst/logo-burst-vanilla.ts @@ -285,25 +285,37 @@ export function createLogoBurst( let dark = resolveDark(themeOf(options.theme)) options.onThemeChange?.(dark) + /** 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 size.w = w size.h = h - canvas.width = Math.floor(w * dpr) - canvas.height = Math.floor(h * dpr) + const width = Math.floor(w * dpr) + const height = 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) - // Resizing clears the canvas, and a reduced-motion burst isn't looping. - paintStill() + return changed } resize() - const ro = new ResizeObserver(resize) + // Resizing cleared the canvas; paint now, or it shows blank for a frame. + // `paint` reads the clock rather than a step, so painting early is safe, + // and it only queues the next frame when the burst is looping. + const ro = new ResizeObserver(() => { + if (!resize() || !running) return + cancelAnimationFrame(raf) + paint(performance.now()) + }) if (canvas.parentElement) ro.observe(canvas.parentElement) const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") diff --git a/registry/phosphor-score/phosphor-score-vanilla.ts b/registry/phosphor-score/phosphor-score-vanilla.ts index 343dc2e..ac45c40 100644 --- a/registry/phosphor-score/phosphor-score-vanilla.ts +++ b/registry/phosphor-score/phosphor-score-vanilla.ts @@ -580,13 +580,14 @@ export function createPhosphorScore( let clock = 0 let view = makeView(1, 1, 0, 0, 0) + /** Sizes the canvas to its parent; true when the backing store changed. */ 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 === size.w && h === size.h && dpr === size.dpr) return + if (w === size.w && h === size.h && dpr === size.dpr) return false size.w = w size.h = h size.dpr = dpr @@ -595,6 +596,7 @@ export function createPhosphorScore( canvas.style.width = `${w}px` canvas.style.height = `${h}px` wake() + return true } const shouldAnimate = () => @@ -615,7 +617,10 @@ export function createPhosphorScore( wake() } - const ro = new ResizeObserver(resize) + // Resizing cleared the canvas; draw now, or it shows blank for a frame. + const ro = new ResizeObserver(() => { + if (resize() && running) draw() + }) if (canvas.parentElement) ro.observe(canvas.parentElement) const io = new IntersectionObserver(([entry]) => { @@ -667,7 +672,9 @@ export function createPhosphorScore( } draw() - if (animate) raf = requestAnimationFrame(tick) + // A pixel-ratio change above resized, and its wake may have queued the + // next frame already; a second request would run two loops. + if (animate && !raf) raf = requestAnimationFrame(tick) } /** Every note whose time was crossed since the last frame flares once; no frame can skip one. */ diff --git a/registry/radiant-lines/radiant-lines-vanilla.ts b/registry/radiant-lines/radiant-lines-vanilla.ts index 08c3904..c15b5a9 100644 --- a/registry/radiant-lines/radiant-lines-vanilla.ts +++ b/registry/radiant-lines/radiant-lines-vanilla.ts @@ -181,21 +181,29 @@ export function createRadiantLines( let lastScrollT = performance.now() let attached: Window | HTMLElement | null = null + /** + * Sizes the canvas to its parent; true when the backing store changed. + * Assigning the same size still clears the canvas, so it only assigns on + * a real change. + */ 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 size.w = w size.h = h - canvas.width = Math.floor(w * dpr) - canvas.height = Math.floor(h * dpr) + const width = Math.floor(w * dpr) + const height = 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` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) - // Resizing clears the canvas, so a paused field needs a fresh frame. wake() + return true } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") @@ -264,6 +272,15 @@ export function createRadiantLines( raf = requestAnimationFrame(tick) } + /** Draws in place now, without advancing; the tick carries on if live. */ + function redraw() { + if (destroyed) return + cancelAnimationFrame(raf) + raf = 0 + lastFrame = performance.now() + tick(lastFrame) + } + // An arrow, not a declaration, so TypeScript keeps `ctx` narrowed inside. const tick = (now: number) => { raf = 0 @@ -368,7 +385,10 @@ export function createRadiantLines( } resize() - 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/ring-tower/ring-tower-vanilla.ts b/registry/ring-tower/ring-tower-vanilla.ts index 2dcd7cc..a8b9779 100644 --- a/registry/ring-tower/ring-tower-vanilla.ts +++ b/registry/ring-tower/ring-tower-vanilla.ts @@ -451,15 +451,23 @@ export function createRingTower( } 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)") @@ -615,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 diff --git a/registry/shader-anime-fire/shader-anime-fire-vanilla.ts b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts index 1224f52..a5dec71 100644 --- a/registry/shader-anime-fire/shader-anime-fire-vanilla.ts +++ b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts @@ -407,19 +407,28 @@ export function createShaderAnimeFire( // Flame edges are crisp, so render a bit past 1x, but not a full 2x, // which would quadruple the fbm work on retina screens. let dpr = 1 + /** + * Sizes the canvas to its parent; true when the backing store changed. + * Assigning the same size still clears the canvas, so it only assigns on + * a real change. + */ const resize = () => { 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-vanilla.ts b/registry/shader-gradient/shader-gradient-vanilla.ts index ca822bb..9ea4872 100644 --- a/registry/shader-gradient/shader-gradient-vanilla.ts +++ b/registry/shader-gradient/shader-gradient-vanilla.ts @@ -393,19 +393,25 @@ export function createShaderGradient( // The gradient is soft enough that one sample per CSS pixel is plenty; // it also keeps the grain the same size on every screen. let pixel = 1 + /** Sizes the canvas to its parent; true when the backing store changed. */ const resize = () => { 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-vanilla.ts b/registry/shader-metal/shader-metal-vanilla.ts index 7d8869d..d73cfe9 100644 --- a/registry/shader-metal/shader-metal-vanilla.ts +++ b/registry/shader-metal/shader-metal-vanilla.ts @@ -437,23 +437,29 @@ export function createShaderMetal( if (!initGl()) return null // Ribbon edges are hard, so this one renders at device resolution (up to 2x). + // Returns true when the backing store changed. const resize = () => { 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-vanilla.ts b/registry/shader-sky/shader-sky-vanilla.ts index d34cd25..40f295b 100644 --- a/registry/shader-sky/shader-sky-vanilla.ts +++ b/registry/shader-sky/shader-sky-vanilla.ts @@ -600,20 +600,26 @@ export function createShaderSky( // Device pixels per CSS pixel actually rendered (after the pixel cap). let pixelScale = 1 + /** Sizes the canvas to its parent; true when the backing store changed. */ const resize = () => { 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]) => {