From debeb940de17b76c636accebec237b39e758323a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 21:34:47 +0530 Subject: [PATCH 01/49] Give the docs the monitor-bench palette and new type. Doto sets component names in dot matrix, Hanken Grotesk carries the reading text, and Martian Mono the code; colour comes from bench, sheet, plate and screen tokens so only the live components add colour. --- app/globals.css | 293 ++++++++++++++++++++++++++++++------------------ app/layout.tsx | 25 ++++- 2 files changed, 203 insertions(+), 115 deletions(-) diff --git a/app/globals.css b/app/globals.css index a90d256..e00cbac 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,6 +9,14 @@ @theme inline { --font-heading: var(--font-sans); --font-sans: var(--font-sans); + --color-bench: var(--bench); + --color-sheet: var(--sheet); + --color-plate: var(--plate); + --color-screen: var(--screen); + --color-ink: var(--ink); + --color-graphite: var(--graphite); + --color-wash: var(--wash); + --color-rule: var(--rule); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -98,75 +106,50 @@ } } +/* + * v2 tokens — "monitor bench". + * The chrome is a quiet, achromatic bench; the only colour on the page comes + * from the components running on their screens. + * + * bench page ground: sidebar, table of contents + * sheet the inset panel the page content sits on + * plate surfaces on the sheet: code, controls, tables, menus + * screen the stage a live component renders on + * ink text and primary actions + * graphite secondary text + * wash hover and pressed fills + * rule borders and dividers + */ :root { color-scheme: light; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --bench: #eeefef; + --sheet: #ffffff; + --plate: #f6f7f7; + --screen: #ffffff; + --ink: #141517; + --graphite: #5e6269; + --wash: #e6e8e9; + --rule: #d8dadc; + + /* Subpixel triad — the "this one is on" indicator. */ + --triad-r: #ff3b3b; + --triad-g: #1fc760; + --triad-b: #3563ff; + --radius: 0.5rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --radius-screen: 14px; } .dark { color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --bench: #1a1b1e; + --sheet: #131416; + --plate: #1c1e21; + --screen: #0e0f10; + --ink: #ecedee; + --graphite: #9da1a8; + --wash: #2b2d32; + --rule: #323439; } /* Before `html.dark` exists, :root is light. Keep system-dark users on dark @@ -174,56 +157,74 @@ @media (prefers-color-scheme: dark) { :root:not(.light) { color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - --color-fd-background: hsl(0, 0%, 7.04%); - --color-fd-foreground: hsl(0, 0%, 92%); - --color-fd-muted: hsl(0, 0%, 12.9%); - --color-fd-muted-foreground: hsla(0, 0%, 70%, 0.8); - --color-fd-popover: hsl(0, 0%, 11.6%); - --color-fd-popover-foreground: hsl(0, 0%, 86.9%); - --color-fd-card: hsl(0, 0%, 9.8%); - --color-fd-card-foreground: hsl(0, 0%, 98%); - --color-fd-border: hsla(0, 0%, 40%, 20%); - --color-fd-primary: hsl(0, 0%, 98%); - --color-fd-primary-foreground: hsl(0, 0%, 9%); - --color-fd-secondary: hsl(0, 0%, 12.9%); - --color-fd-secondary-foreground: hsl(0, 0%, 92%); - --color-fd-accent: hsla(0, 0%, 40.9%, 30%); - --color-fd-accent-foreground: hsl(0, 0%, 90%); - --color-fd-ring: hsl(0, 0%, 54.9%); + --bench: #1a1b1e; + --sheet: #131416; + --plate: #1c1e21; + --screen: #0e0f10; + --ink: #ecedee; + --graphite: #9da1a8; + --wash: #2b2d32; + --rule: #323439; } } +/* shadcn + Fumadocs roles, mapped onto the bench tokens. */ +:root, +.dark { + --background: var(--bench); + --foreground: var(--ink); + --card: var(--plate); + --card-foreground: var(--ink); + --popover: var(--plate); + --popover-foreground: var(--ink); + --primary: var(--ink); + --primary-foreground: var(--bench); + --secondary: var(--wash); + --secondary-foreground: var(--ink); + --muted: var(--wash); + --muted-foreground: var(--graphite); + --accent: var(--wash); + --accent-foreground: var(--ink); + --destructive: oklch(0.577 0.245 27.325); + --border: var(--rule); + --input: var(--rule); + --ring: color-mix(in oklab, var(--ink) 55%, transparent); + --chart-1: var(--triad-r); + --chart-2: var(--triad-g); + --chart-3: var(--triad-b); + --chart-4: var(--graphite); + --chart-5: var(--ink); + --sidebar: var(--bench); + --sidebar-foreground: var(--ink); + --sidebar-primary: var(--ink); + --sidebar-primary-foreground: var(--bench); + --sidebar-accent: var(--wash); + --sidebar-accent-foreground: var(--ink); + --sidebar-border: var(--rule); + --sidebar-ring: var(--ring); + + --color-fd-background: var(--bench); + --color-fd-foreground: var(--ink); + --color-fd-muted: var(--wash); + --color-fd-muted-foreground: var(--graphite); + --color-fd-popover: var(--plate); + --color-fd-popover-foreground: var(--ink); + --color-fd-card: var(--plate); + --color-fd-card-foreground: var(--ink); + --color-fd-border: var(--rule); + --color-fd-primary: var(--ink); + --color-fd-primary-foreground: var(--bench); + --color-fd-secondary: var(--wash); + --color-fd-secondary-foreground: var(--ink); + --color-fd-accent: var(--wash); + --color-fd-accent-foreground: var(--ink); + --color-fd-ring: var(--ring); +} + +.dark { + --destructive: oklch(0.704 0.191 22.216); +} + @layer base { * { @apply border-border outline-ring/50; @@ -266,6 +267,18 @@ @apply bg-background text-foreground; } + ::selection { + background-color: var(--ink); + color: var(--bench); + } + + code, + kbd, + pre, + samp { + font-variation-settings: "wdth" 87.5; + } + html { @apply bg-background font-sans; scrollbar-gutter: stable; @@ -277,6 +290,68 @@ } } +/* Dot-matrix display face. Only for component names and the wordmark — + never for running text or labels. */ +@utility font-display { + font-family: var(--font-display), ui-monospace, monospace; + font-weight: 900; + font-variation-settings: "ROND" 100; + letter-spacing: -0.02em; + font-kerning: none; +} + +/* Reading column. Fumadocs' typography plugin uses :where(), so these win. */ +.prose { + --tw-prose-body: color-mix(in oklab, var(--ink) 86%, var(--bench)); + --tw-prose-headings: var(--ink); + line-height: 1.7; +} + +.prose h2 { + margin-top: 3.5rem; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.015em; +} + +.prose h3 { + margin-top: 2.25rem; + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.prose a:not([data-card]) { + font-weight: inherit; + text-decoration-color: color-mix(in oklab, var(--ink) 30%, transparent); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; + transition: text-decoration-color 150ms; +} + +.prose a:not([data-card]):hover { + text-decoration-color: var(--ink); +} + +.prose strong { + font-weight: 600; +} + +.prose :not(pre) > code { + padding: 0.1em 0.35em; + border: 1px solid var(--rule); + border-radius: 5px; + background: var(--plate); + font-size: 0.8125em; +} + +.prose kbd { + border-color: var(--rule); + background: var(--plate); + box-shadow: 0 1px 0 var(--rule); +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/app/layout.tsx b/app/layout.tsx index 4f16338..d15ef6d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import { Geist, Geist_Mono } from "next/font/google" +import { Doto, Hanken_Grotesk, Martian_Mono } from "next/font/google" import { RootProvider } from "fumadocs-ui/provider/next" import type { Metadata, Viewport } from "next" import type { ReactNode } from "react" @@ -94,17 +94,25 @@ export const metadata: Metadata = { export const viewport: Viewport = { themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#ffffff" }, - { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + { media: "(prefers-color-scheme: light)", color: "#eeefef" }, + { media: "(prefers-color-scheme: dark)", color: "#1a1b1e" }, ], colorScheme: "light dark", } -const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }) +const fontSans = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-sans" }) -const fontMono = Geist_Mono({ +const fontMono = Martian_Mono({ subsets: ["latin"], variable: "--font-mono", + axes: ["wdth"], +}) + +/** Dot-matrix face for component names — rendered, like the components. */ +const fontDisplay = Doto({ + subsets: ["latin"], + variable: "--font-display", + axes: ["ROND"], }) export default function RootLayout({ @@ -120,7 +128,12 @@ export default function RootLayout({ \n\n\n\n\n \n \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 429840e..3a40724 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, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor 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\n * and exit flare — kept small so it sits on the note, not a disc.\n */\n glow?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: 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\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-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\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, 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\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 100)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\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\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\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 const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let dark = resolveDark(themeOf(options.theme))\n let rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n let dtSmooth = 1 / 60\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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\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 rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\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 rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(resolveColor(options.color, dark))\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n let frameFit = 0.78\n\n const project = (x: number, y: number): Pt => {\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const rx = x * cosZ - y * sinZ + cam.y\n const ry = x * sinZ + y * cosZ + cam.x\n const s = frameFit\n return {\n x: size.w * 0.5 + rx * s,\n y: size.h * 0.5 + ry * s,\n s,\n z: 0,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, alpha: number) => {\n const r = Math.max(1.2, radius * pt.s)\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, rgba(rgb, alpha))\n g.addColorStop(0.28, rgba(rgb, alpha * 0.42))\n g.addColorStop(0.62, rgba(rgb, alpha * 0.1))\n g.addColorStop(1, rgba(rgb, 0))\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const raw = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n dtSmooth += (raw - dtSmooth) * 0.2\n const dt = dtSmooth\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const swayOn = options.sway !== false && !reduce\n cam.x = swayOn ? Math.sin(clock * 0.31) * 8 : 0\n cam.y = swayOn ? Math.cos(clock * 0.23) * 10 : 0\n cam.z = 0\n frameFit = 0.78\n\n const pad = Math.max(28, Math.min(w, h) * 0.1)\n const innerW = Math.max(1, w - pad * 2)\n const innerH = Math.max(1, h - pad * 2)\n let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28)\n const maxHalf = innerW / 2 / frameFit\n let gap = maxHalf * 0.4\n if (gap < sp * 7) {\n sp = clamp(maxHalf * 0.4 / 7, 10, 28)\n gap = maxHalf * 0.4\n }\n gap = Math.max(sp * 7, gap)\n const topY = -innerH / 2 / frameFit\n const playY = (innerH / 2 - sp * 2) / frameFit\n const ppb = Math.max(28, (playY - topY) / 7.6)\n const bloom = glowOf(options.glow) / 50\n const color = parseColor(resolveColor(options.color, dark))\n rgb = color\n\n ctx.imageSmoothingEnabled = true\n ctx.globalCompositeOperation = \"source-over\"\n if (dark) {\n ctx.fillStyle = DARK_BG\n ctx.fillRect(0, 0, w, h)\n } else {\n ctx.clearRect(0, 0, w, h)\n }\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = (playY - innerH / 2 / frameFit) / ppb\n const visMax = (playY - topY) / ppb\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n const staffY0 = topY - h * 0.4 / frameFit\n const staffY1 = playY + h * 0.25 / frameFit\n ctx.lineCap = \"butt\"\n ctx.lineJoin = \"miter\"\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const x = pitchX(staff, line * 2, gap, sp)\n const a = project(x, staffY0)\n const b = project(x, staffY1)\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const alpha = 0.14 + nearPlay(d) * 0.2\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, alpha)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playYScreen = project(0, playY).y\n ctx.save()\n ctx.lineCap = \"butt\"\n ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom))\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom))\n ctx.lineWidth = 0.75\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.restore()\n\n const bloomOp = dark ? \"lighter\" : \"source-over\"\n ctx.globalCompositeOperation = bloomOp\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.04 && bloom > 0) {\n const halo = (5 + intens * 9) * bloom\n glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom))\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.save()\n if (intens > 0.2 && bloom > 0) {\n ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45)\n ctx.shadowBlur = (3.5 + intens * 5) * bloom\n }\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.restore()\n ctx.globalCompositeOperation = bloomOp\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.38,\n size: 10,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade)\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 22 * bloom * (1 + t * 0.4) * pt.s\n const flareH = 1.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n rgb = parseColor(resolveColor(options.color, dark))\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\nfunction PhosphorEdgeFade({\n edge,\n dark,\n}: {\n edge: \"top\" | \"bottom\"\n dark: boolean\n}) {\n const isTop = edge === \"top\"\n const pos = isTop ? \"top-0\" : \"bottom-0\"\n const mask = isTop\n ? \"mask-[linear-gradient(to_bottom,black,transparent)]\"\n : \"mask-[linear-gradient(to_top,black,transparent)]\"\n return (\n <>\n \n \n \n \n )\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. 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 theme = \"auto\",\n}: PhosphorScoreProps) {\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 = createPhosphorScore(canvas, {\n color,\n glow,\n speed,\n density,\n sway,\n seed,\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 color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n }, [color, glow, speed, density, sway, seed, theme])\n\n return (\n \n \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 * 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\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-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\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\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 * 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\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 if (dark) {\n ctx!.fillStyle = DARK_BG\n ctx!.fillRect(0, 0, w, h)\n } else {\n ctx!.clearRect(0, 0, w, h)\n }\n if (w < 2 || h < 2) return\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 function line(x0: number, y0: number, x1: number, y1: number) {\n ctx!.beginPath()\n ctx!.moveTo(x0, y0)\n ctx!.lineTo(x1, y1)\n ctx!.stroke()\n }\n\n function drawStaves(l: Layout) {\n const { h } = size\n ctx!.lineCap = \"butt\"\n ctx!.lineWidth = 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, 0, x, h)\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 const d = wrapDelta(bar, beat)\n const y = yOf(l, d)\n if (y < -4 || y > h + 4) continue\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 function drawScore(s: Score, l: Layout, opacity: number, bloom: number) {\n if (opacity <= 0) return\n const { h } = size\n const minD = (l.playY - h - l.ppb) / l.ppb\n const maxD = (l.playY + l.ppb) / l.ppb\n const inView = (d: number) => d >= minD && d <= maxD\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 const d = wrapDelta(mark.t, beat)\n if (!inView(d)) continue\n ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85)\n ctx!.fillText(mark.text, l.cx, yOf(l, d) + sp * 0.9)\n }\n ctx!.lineWidth = 1\n for (const pin of s.hairpins) {\n const d0 = wrapDelta(pin.t, beat)\n const d1 = d0 + pin.dur\n if (!inView(d0) && !inView(d1)) continue\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 ctx!.beginPath()\n ctx!.moveTo(l.cx - half, yOf(l, wide))\n ctx!.lineTo(l.cx, yOf(l, narrow))\n ctx!.lineTo(l.cx + half, yOf(l, wide))\n ctx!.stroke()\n }\n\n // Slurs curve on the inner side, opposite the stems.\n ctx!.lineWidth = Math.max(1, sp * 0.1)\n for (const slur of s.slurs) {\n const d0 = wrapDelta(slur.first.t, beat)\n const d1 = d0 + (slur.last.t - slur.first.t)\n if (!inView(d0) && !inView(d1)) continue\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(rgb, alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5)\n ctx!.beginPath()\n ctx!.moveTo(x0, y0)\n ctx!.quadraticCurveTo(xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1)\n ctx!.stroke()\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 d0 = wrapDelta(stem.t0, beat)\n const d1 = d0 + (stem.t1 - stem.t0)\n if (!inView(d0) && !inView(d1)) continue\n const out = stem.staff === 0 ? -1 : 1\n const tipX = xOf(l, stem.staff, stem.tip)\n ctx!.lineWidth = 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 ctx!.lineWidth = 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 // Ledger lines, parallel to the staff lines.\n ctx!.lineWidth = 1\n for (const n of s.notes) {\n if (n.pitch > -2 && n.pitch < 10) continue\n const d = wrapDelta(n.t, beat)\n if (!inView(d)) continue\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 // 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 const d = wrapDelta(n.t, beat)\n const lit = litAt(d)\n if (lit < 0.03 || !inView(d)) continue\n const r = sp * (1.1 + lit * 1.5) * bloom\n ctx!.globalAlpha =\n lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity\n ctx!.drawImage(\n glowSprite,\n xOf(l, n.staff, n.pitch) - r,\n yOf(l, d) - r,\n r * 2,\n r * 2\n )\n }\n ctx!.globalAlpha = 1\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 const d = wrapDelta(n.t, beat)\n if (!inView(d)) continue\n const x = xOf(l, n.staff, n.pitch)\n const y = yOf(l, d)\n const color = rgba(rgb, alphaAt(d))\n ctx!.beginPath()\n ctx!.ellipse(x, y, 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 ? \"♯\" : \"♭\", x, y + ry + sp * 0.95)\n }\n }\n }\n\n function drawPlayhead(l: Layout, bloom: number) {\n const { w } = size\n const y = Math.round(l.playY) + 0.5\n if (bloom > 0) {\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n ctx!.globalAlpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom)\n const band = l.sp * 1.4 * Math.max(0.6, bloom)\n ctx!.drawImage(glowSprite, 0, y - band, w, band * 2)\n ctx!.globalAlpha = 1\n ctx!.globalCompositeOperation = \"source-over\"\n }\n ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55)\n ctx!.lineWidth = 1\n line(0, y, w, y)\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 ctx!.globalAlpha = fade * (dark ? 0.8 : 0.4)\n ctx!.drawImage(glowSprite, x - r, l.playY - r, r * 2, r * 2)\n // A streak along the playhead.\n const streak = l.sp * (2.5 + k * 3) * bloom\n ctx!.globalAlpha = fade * (dark ? 0.9 : 0.5)\n ctx!.drawImage(glowSprite, x - streak, l.playY - 1.5, streak * 2, 3)\n }\n ctx!.globalAlpha = 1\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 const solid = dark ? \"rgba(5,5,5,1)\" : \"rgba(0,0,0,1)\"\n const clear = dark ? \"rgba(5,5,5,0)\" : \"rgba(0,0,0,0)\"\n ctx!.globalCompositeOperation = dark ? \"source-over\" : \"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 * Background before the first frame, so dark mode never flashes light.\n * `#050505` is `DARK_BG`; Tailwind needs the literal.\n */\nconst SURFACE: Record = {\n auto: \"bg-background dark:bg-[#050505]\",\n dark: \"bg-[#050505]\",\n light: \"bg-background\",\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 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 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 theme,\n })\n }, [color, glow, speed, density, sway, seed, theme])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx index 5737f75..b078fbd 100644 --- a/registry/phosphor-score/phosphor-score-demo.tsx +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -12,10 +12,10 @@ import { ComponentPreview } from "@/components/component-preview" import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { - DARK_BG, DARK_COLOR, DEFAULT_DENSITY, DEFAULT_GLOW, + DEFAULT_SEED, DEFAULT_SPEED, LIGHT_COLOR, PhosphorScore, @@ -39,6 +39,7 @@ export function PhosphorScoreDemo() { glow: DEFAULT_GLOW, speed: DEFAULT_SPEED, density: DEFAULT_DENSITY, + seed: DEFAULT_SEED, sway: true, }), [stock] @@ -63,15 +64,13 @@ export function PhosphorScoreDemo() { title="Phosphor Score" stageClassName="min-h-0 overflow-hidden p-0" > -
+
@@ -88,6 +87,7 @@ export function PhosphorScoreDemo() { speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, density: props.density === DEFAULT_DENSITY ? undefined : props.density, + seed: props.seed === DEFAULT_SEED ? undefined : props.seed, sway: props.sway ? undefined : false, }} > @@ -120,6 +120,14 @@ export function PhosphorScoreDemo() { step={0.05} onChange={(v) => updateProp("density", v)} /> + updateProp("seed", v)} + /> >> 0 @@ -120,56 +124,26 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } -function wrapDelta(t: number, now: number, loop: number) { - let d = t - now - d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2 - return d -} - -function parseColor(input?: string): Rgb { - const raw = (input ?? DEFAULT_COLOR).trim() - if (/^#[0-9a-fA-F]{6}$/.test(raw)) { - return [ - parseInt(raw.slice(1, 3), 16), - parseInt(raw.slice(3, 5), 16), - parseInt(raw.slice(5, 7), 16), - ] - } - if (/^#[0-9a-fA-F]{3}$/.test(raw)) { - return [ - parseInt(raw[1]! + raw[1], 16), - parseInt(raw[2]! + raw[2], 16), - parseInt(raw[3]! + raw[3], 16), - ] - } - return [77, 255, 106] -} - -function rgba(rgb: Rgb, a: number) { - return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})` -} - -function glowOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GLOW - return clamp(value, 0, 100) +/** Signed beats from `now` to `t`, taking the shorter way round the loop. */ +function wrapDelta(t: number, now: number) { + const d = t - now + return ( + ((((d + LOOP_BEATS / 2) % LOOP_BEATS) + LOOP_BEATS) % LOOP_BEATS) - + LOOP_BEATS / 2 + ) } -function speedOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SPEED - return clamp(value, 0.15, 4) +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 densityOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) { - return DEFAULT_DENSITY - } - return clamp(value, 0.35, 1.85) -} - -function seedOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SEED - return Math.round(value) >>> 0 -} +const glowOf = (value?: number) => numberOr(value, DEFAULT_GLOW, 0, 100) +const speedOf = (value?: number) => numberOr(value, DEFAULT_SPEED, 0.15, 4) +const densityOf = (value?: number) => + numberOr(value, DEFAULT_DENSITY, 0.35, 1.85) +const seedOf = (value?: number) => + Math.round(numberOr(value, DEFAULT_SEED, 0, 2 ** 32 - 1)) >>> 0 function themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme { return value === "light" || value === "dark" || value === "auto" @@ -210,166 +184,220 @@ export function resolveBg(dark: boolean) { return dark ? DARK_BG : LIGHT_BG } -type Score = { - notes: Note[] - marks: Mark[] - hairpins: Hairpin[] - slurs: Slur[] - links: Link[] +/** Any CSS color → rgb, using the canvas' own parser. */ +function parseColor( + ctx: CanvasRenderingContext2D, + input: string, + fallback: string +): Rgb { + ctx.fillStyle = fallback + ctx.fillStyle = input + const value = String(ctx.fillStyle) + if (value.startsWith("#") && value.length === 7) { + return [ + parseInt(value.slice(1, 3), 16), + parseInt(value.slice(3, 5), 16), + parseInt(value.slice(5, 7), 16), + ] + } + const parts = value.match(/[\d.]+/g)?.map(Number) ?? [] + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0] +} + +function rgba(rgb: Rgb, a: number) { + return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${clamp(a, 0, 1)})` } function generateScore(seed: number, density: number): Score { const rng = mulberry32(seed) const notes: Note[] = [] + const stems: Stem[] = [] const marks: Mark[] = [] const hairpins: Hairpin[] = [] const slurs: Slur[] = [] - const links: Link[] = [] - let group = 1 - const dens = densityOf(density) - const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]! - - const pushNote = ( - t: number, - pitch: number, - dur: number, - staff: 0 | 1, - beam: number - ) => { - notes.push({ + const note = (t: number, pitch: number, staff: Staff, hollow = false) => { + const n: Note = { t, - pitch: clamp(pitch, -3, 11), - dur, + pitch: clamp(Math.round(pitch), -3, 11), staff, - beam, + hollow, acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0, + } + notes.push(n) + return n + } + + const addStem = ( + staff: Staff, + list: Note[], + beams: Stem["beams"], + chord = false + ) => { + const pitches = list.map((n) => n.pitch) + const tip = + staff === 0 ? Math.min(...pitches) - STEM : Math.max(...pitches) + STEM + stems.push({ + staff, + notes: list, + t0: list[0]!.t, + t1: list[list.length - 1]!.t, + tip, + beams: list.length > 1 && !chord ? beams : 0, + chord, }) } - const addRun = ( - staff: 0 | 1, + const run = ( + staff: Staff, start: number, pitch: number, count: number, - step: number, - dir: number + step: number ) => { - const beam = group++ + let dir = rng() < 0.5 ? 1 : -1 let p = pitch + const list: Note[] = [] for (let i = 0; i < count; i++) { - if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam) - const hop = rng() < 0.18 ? 2 : 1 - p += dir * hop - if (p > 10 || p < -2) dir *= -1 + if (i === 0 || rng() > 0.12 / density) + list.push(note(start + i * step, p, staff)) + p += dir * (rng() < 0.18 ? 2 : 1) + if (p >= 10 || p <= -2) { + dir = -dir + p = clamp(p, -2, 10) + } } - if (count >= 4 && rng() < 0.45) { - slurs.push({ - t0: start, - t1: start + (count - 1) * step, - p0: pitch, - p1: p, - staff, - }) + // A run thinned down to one note is drawn as a lone quarter. + addStem(staff, list, step <= 0.25 ? 2 : 1) + if (list.length >= 4 && rng() < 0.45) { + slurs.push({ staff, first: list[0]!, last: list[list.length - 1]! }) } } - const addArp = (staff: 0 | 1, start: number, root: number, step: number) => { - const pattern = [0, 2, 4, 7, 4, 2, 0, -1] - const beam = group++ - for (let i = 0; i < pattern.length; i++) { - pushNote(start + i * step, root + pattern[i]!, step, staff, beam) - } + const arpeggio = ( + staff: Staff, + start: number, + root: number, + step: number + ) => { + const shape = [0, 2, 4, 7, 4, 2, 0, -1] + const list = shape.map((offset, i) => + note(start + i * step, root + offset, staff) + ) + addStem(staff, list, step <= 0.25 ? 2 : 1) } - const addChord = (staff: 0 | 1, t: number, root: number) => { - const beam = -group++ - pushNote(t, root, 1, staff, beam) - pushNote(t, root + 2, 1, staff, beam) - if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam) + const chord = (staff: Staff, t: number, root: number, hollow: boolean) => { + const list = [ + note(t, root, staff, hollow), + note(t, root + 2, staff, hollow), + ] + if (rng() < 0.7) list.push(note(t, root + 4, staff, hollow)) + addStem(staff, list, 0, true) } let t = 0 - while (t < LOOP_BEATS - 1) { + while (t < LOOP_END) { + const room = LOOP_END - t const kind = rng() - const leftBusy = rng() < 0.55 + dens * 0.15 - const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4 - const step = rng() < 0.28 * dens ? 0.125 : 0.25 - const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0) + const lead: Staff = rng() < 0.55 ? 0 : 1 + const other: Staff = lead === 0 ? 1 : 0 + const phraseStart = t if (kind < 0.5) { - addRun( - leftBusy ? 0 : 1, - t, - startPitch, - runLen, - step, - rng() < 0.5 ? 1 : -1 - ) - if (rng() < 0.55 * dens) { - addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4)) + const count = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4 + const step = rng() < 0.35 * density ? 0.25 : 0.5 + const length = count * step + if (length > room) break + run(lead, t, Math.floor(rng() * 7) + 1, count, step) + if (rng() < 0.55 * density) { + chord(other, t, 1 + Math.floor(rng() * 4), length >= 2 && rng() < 0.5) } - t += runLen * step + t += length } else if (kind < 0.72) { - addArp(0, t, 1 + Math.floor(rng() * 5), 0.25) - if (rng() < 0.7 * dens) { - addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1) - } + if (room < 2) break + arpeggio(lead, t, 1 + Math.floor(rng() * 4), 0.25) + if (rng() < 0.7 * density) + run(other, t, Math.floor(rng() * 4) + 1, 4, 0.5) t += 2 - } else if (kind < 0.86) { - addChord(0, t, 3 + Math.floor(rng() * 4)) - addChord(1, t, Math.floor(rng() * 4)) - if (rng() < 0.5) { - links.push({ - t, - p0: 4, - p1: 4, - }) - } - t += rng() < 0.5 ? 1 : 2 + } else if (kind < 0.88) { + const hollow = rng() < 0.45 + const length = hollow ? 2 : 1 + if (length > room) break + chord(0, t, 2 + Math.floor(rng() * 4), hollow) + chord(1, t, Math.floor(rng() * 4), hollow) + t += length } else { - t += rng() < 0.5 ? 0.5 : 1 + // A rest. Sparser scores rest longer. + t += density < 0.8 ? 1 : 0.5 + continue } - if (rng() < 0.18) { + if (rng() < 0.2) marks.push({ - t, - staff: rng() < 0.5 ? 0 : 1, - text: pick(DYNAMICS), - pitch: rng() < 0.5 ? -3.6 : 11.4, - }) - } - if (rng() < 0.12) { - hairpins.push({ - t, - dur: 2 + Math.floor(rng() * 3), - staff: rng() < 0.5 ? 0 : 1, - open: rng() < 0.5, + t: phraseStart, + text: DYNAMICS[Math.floor(rng() * DYNAMICS.length)]!, }) + if (rng() < 0.14) { + const dur = Math.min(2 + Math.floor(rng() * 3), LOOP_END - phraseStart) + if (dur >= 1) hairpins.push({ t: phraseStart, dur, opening: rng() < 0.5 }) } } - return { notes, marks, hairpins, slurs, links } + return { notes, stems, marks, hairpins, slurs } } function makeDust(seed: number): Dust[] { const rng = mulberry32(seed ^ 0x9e3779b9) - const dust: Dust[] = [] - for (let i = 0; i < 90; i++) { - dust.push({ - x: (rng() - 0.5) * 2, - y: (rng() - 0.5) * 2, - z: rng(), - s: 0.4 + rng() * 1.4, - a: 0.08 + rng() * 0.22, - }) + return Array.from({ length: 90 }, () => ({ + x: rng(), + y: rng(), + z: rng(), + s: 0.4 + rng() * 1.4, + a: 0.08 + rng() * 0.22, + })) +} + +/** A soft round glow in `rgb`, drawn once and stamped with `drawImage`. */ +function makeGlowSprite(rgb: Rgb) { + const size = 64 + const sprite = document.createElement("canvas") + sprite.width = size + sprite.height = size + const g = sprite.getContext("2d") + if (!g) return sprite + const grad = g.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2 + ) + grad.addColorStop(0, rgba(rgb, 1)) + grad.addColorStop(0.25, rgba(rgb, 0.45)) + grad.addColorStop(0.6, rgba(rgb, 0.1)) + grad.addColorStop(1, rgba(rgb, 0)) + g.fillStyle = grad + g.fillRect(0, 0, size, size) + return sprite +} + +/** Brightness at the playhead: ramps in just before, decays after (phosphor persistence). */ +function litAt(d: number) { + if (d > 0.3) return 0 + if (d >= 0) { + const k = 1 - d / 0.3 + return k * k } - return dust + return Math.exp(d * 2.4) } /** - * Vertical phosphor sheet music — notation falls toward a playhead, - * blooms, then exits in a flare. Canvas 2D, CRT-green by default. + * Vertical phosphor sheet music: a grand staff scrolls down toward a + * playhead, each note blooms and flares as it plays, then fades with a + * phosphor afterglow. Canvas 2D; pauses off-screen and in hidden tabs, and + * holds a still frame under `prefers-reduced-motion`. */ export function createPhosphorScore( canvas: HTMLCanvasElement, @@ -388,21 +416,35 @@ export function createPhosphorScore( const ctx = canvas.getContext("2d", { alpha: true }) if (!ctx) return null - const size = { w: 0, h: 0 } let score = generateScore(seedOf(options.seed), densityOf(options.density)) + let previous: Score | null = null + let rebuildAge = REBUILD_FADE let dust = makeDust(seedOf(options.seed)) + let dark = resolveDark(themeOf(options.theme)) - let rgb = parseColor(resolveColor(options.color, dark)) + let rgb: Rgb = [0, 0, 0] + let glowSprite = document.createElement("canvas") + const recolor = () => { + rgb = parseColor( + ctx, + resolveColor(options.color, dark), + dark ? DARK_COLOR : LIGHT_COLOR + ) + glowSprite = makeGlowSprite(rgb) + } + recolor() options.onThemeChange?.(dark) + const flares: Flare[] = [] - const hit = new Set() + const size = { w: 0, h: 0, dpr: 1 } let reduce = false - let raf = 0 + let onScreen = true let running = true - let lastFrame = performance.now() - let elapsed = 8 + let raf = 0 + let lastFrame = 0 + // Start a few bars in, so the first frame already has notes at the playhead. + let beat = 8 let clock = 0 - let dtSmooth = 1 / 60 const resize = () => { const parent = canvas.parentElement @@ -410,467 +452,427 @@ export function createPhosphorScore( 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 size.w = w size.h = h - canvas.width = Math.floor(w * dpr) - canvas.height = Math.floor(h * dpr) + size.dpr = dpr + canvas.width = Math.max(1, Math.floor(w * dpr)) + canvas.height = Math.max(1, Math.floor(h * dpr)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` - ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + wake() } - resize() - const ro = new ResizeObserver(resize) - if (canvas.parentElement) ro.observe(canvas.parentElement) + const shouldAnimate = () => + running && onScreen && !reduce && document.visibilityState !== "hidden" - const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - const onReduce = () => { - reduce = mqReduce.matches + function wake() { + if (!running || raf) return + lastFrame = performance.now() + raf = requestAnimationFrame(tick) } - onReduce() - mqReduce.addEventListener("change", onReduce) const syncTheme = () => { const next = resolveDark(themeOf(options.theme)) if (next === dark) return dark = next - rgb = parseColor(resolveColor(options.color, dark)) + recolor() options.onThemeChange?.(dark) + wake() + } + + 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 mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + const onReduce = () => { + reduce = mqReduce.matches + wake() } + reduce = mqReduce.matches + mqReduce.addEventListener("change", onReduce) + const mo = new MutationObserver(syncTheme) mo.observe(document.documentElement, { attributes: true, - attributeFilter: ["class", "data-theme", "style"], + attributeFilter: ["class", "data-theme"], }) const mqDark = window.matchMedia("(prefers-color-scheme: dark)") mqDark.addEventListener("change", syncTheme) - const rebuild = () => { - score = generateScore(seedOf(options.seed), densityOf(options.density)) - dust = makeDust(seedOf(options.seed)) - rgb = parseColor(resolveColor(options.color, dark)) - hit.clear() - flares.length = 0 - } + function tick(now: number) { + raf = 0 + if (!running) return + if (Math.min(window.devicePixelRatio || 1, 2) !== size.dpr) resize() - type Pt = { x: number; y: number; s: number; z: number } - - const cam = { x: 0, y: 0, z: 0 } - let frameFit = 0.78 - - const project = (x: number, y: number): Pt => { - const cosZ = Math.cos(cam.z) - const sinZ = Math.sin(cam.z) - const rx = x * cosZ - y * sinZ + cam.y - const ry = x * sinZ + y * cosZ + cam.x - const s = frameFit - return { - x: size.w * 0.5 + rx * s, - y: size.h * 0.5 + ry * s, - s, - z: 0, + const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000)) + lastFrame = now + const animate = shouldAnimate() + const before = beat + if (animate) { + clock += dt + beat = (beat + dt * speedOf(options.speed)) % LOOP_BEATS + rebuildAge += dt + spawnFlares(before, beat) + } else { + rebuildAge = REBUILD_FADE + flares.length = 0 + } + for (let i = flares.length - 1; i >= 0; i--) { + flares[i]!.age += dt + if (flares[i]!.age >= FLARE_LIFE) flares.splice(i, 1) } - } - - const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap) - - const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) => - staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5) - - const timeY = (delta: number, ppb: number, playY: number) => - playY - delta * ppb - const nearPlay = (delta: number) => { - if (delta > 0.55) return 0 - if (delta > 0) return 1 - delta / 0.55 - if (delta > -0.28) return 1 - if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07 - return 0 + draw() + if (animate) raf = requestAnimationFrame(tick) } - const drawOval = (pt: Pt, r: number, fill: string) => { - ctx.save() - ctx.translate(pt.x, pt.y) - ctx.rotate(-0.38) - ctx.scale(1, 0.7) - ctx.beginPath() - ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2) - ctx.fillStyle = fill - ctx.fill() - ctx.restore() + /** Every note whose time was crossed since the last frame flares once — no frame can skip one. */ + function spawnFlares(from: number, to: number) { + const span = wrapDelta(to, from) + if (span <= 0) return + for (const n of score.notes) { + const d = wrapDelta(n.t, from) + if (d > 0 && d <= span) { + if (flares.length >= MAX_FLARES) flares.shift() + flares.push({ staff: n.staff, pitch: n.pitch, age: 0 }) + } + } } - const glowBlob = (pt: Pt, radius: number, alpha: number) => { - const r = Math.max(1.2, radius * pt.s) - const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r) - g.addColorStop(0, rgba(rgb, alpha)) - g.addColorStop(0.28, rgba(rgb, alpha * 0.42)) - g.addColorStop(0.62, rgba(rgb, alpha * 0.1)) - g.addColorStop(1, rgba(rgb, 0)) - ctx.fillStyle = g - ctx.beginPath() - ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2) - ctx.fill() - } + function draw() { + const { w, h, dpr } = size + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx!.globalCompositeOperation = "source-over" + ctx!.globalAlpha = 1 + if (dark) { + ctx!.fillStyle = DARK_BG + ctx!.fillRect(0, 0, w, h) + } else { + ctx!.clearRect(0, 0, w, h) + } + if (w < 2 || h < 2) return - const frame = (now: number) => { - if (!running) return - const raw = Math.min(0.05, (now - lastFrame) / 1000) - lastFrame = now - dtSmooth += (raw - dtSmooth) * 0.2 - const dt = dtSmooth - clock += dt - if (!reduce) elapsed += dt * speedOf(options.speed) - const beat = elapsed % LOOP_BEATS - - const w = size.w - const h = size.h const swayOn = options.sway !== false && !reduce - cam.x = swayOn ? Math.sin(clock * 0.31) * 8 : 0 - cam.y = swayOn ? Math.cos(clock * 0.23) * 10 : 0 - cam.z = 0 - frameFit = 0.78 - - const pad = Math.max(28, Math.min(w, h) * 0.1) - const innerW = Math.max(1, w - pad * 2) - const innerH = Math.max(1, h - pad * 2) - let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28) - const maxHalf = innerW / 2 / frameFit - let gap = maxHalf * 0.4 - if (gap < sp * 7) { - sp = clamp(maxHalf * 0.4 / 7, 10, 28) - gap = maxHalf * 0.4 + const cam = { + x: swayOn ? Math.cos(clock * 0.23) * 10 : 0, + y: swayOn ? Math.sin(clock * 0.31) * 8 : 0, } - gap = Math.max(sp * 7, gap) - const topY = -innerH / 2 / frameFit - const playY = (innerH / 2 - sp * 2) / frameFit - const ppb = Math.max(28, (playY - topY) / 7.6) - const bloom = glowOf(options.glow) / 50 - const color = parseColor(resolveColor(options.color, dark)) - rgb = color - - ctx.imageSmoothingEnabled = true - ctx.globalCompositeOperation = "source-over" - if (dark) { - ctx.fillStyle = DARK_BG - ctx.fillRect(0, 0, w, h) - } else { - ctx.clearRect(0, 0, w, h) + const sp = clamp(Math.min(w, h) * 0.021, 5, 11) + const layout = { + sp, + gap: clamp(w * 0.16, sp * 6.5, sp * 12), + ppb: clamp(h * 0.15, sp * 5.2, sp * 11), + cx: w / 2 + cam.x, + playY: h * PLAYHEAD + cam.y, } + const bloom = glowOf(options.glow) / 50 - ctx.save() - ctx.globalAlpha = 1 + // Dust drifts in screen space, beneath everything. for (const d of dust) { - const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1 - const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1 - ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28) - ctx.fillRect(px * w, py * h, d.s, d.s) + const px = (d.x + clock * 0.01 * d.z) % 1 + const py = (d.y + beat * 0.004 * (0.3 + d.z)) % 1 + ctx!.fillStyle = rgba(rgb, dark ? d.a : d.a * 0.35) + ctx!.fillRect(px * w, py * h, d.s, d.s) } - ctx.restore() - const visMin = (playY - innerH / 2 / frameFit) / ppb - const visMax = (playY - topY) / ppb + drawStaves(layout) - const inView = (t: number) => { - const d = wrapDelta(t, beat, LOOP_BEATS) - return d < visMax && d > visMin - } + const fade = previous ? clamp(rebuildAge / REBUILD_FADE, 0, 1) : 1 + if (previous && fade >= 1) previous = null + if (previous) drawScore(previous, layout, 1 - fade, 0) + drawScore(score, layout, fade, bloom) + + drawPlayhead(layout, bloom) + drawFlares(layout, bloom) + drawEdgeFade(w, h) + } + + type Layout = { + sp: number + gap: number + ppb: number + cx: number + playY: number + } + + const xOf = (l: Layout, staff: Staff, pitch: number) => + l.cx + (staff === 0 ? -l.gap : l.gap) + (pitch - 4) * (l.sp / 2) + const yOf = (l: Layout, d: number) => l.playY - d * l.ppb + + function line(x0: number, y0: number, x1: number, y1: number) { + ctx!.beginPath() + ctx!.moveTo(x0, y0) + ctx!.lineTo(x1, y1) + ctx!.stroke() + } - const staffY0 = topY - h * 0.4 / frameFit - const staffY1 = playY + h * 0.25 / frameFit - ctx.lineCap = "butt" - ctx.lineJoin = "miter" - ctx.strokeStyle = rgba(color, 0.22) - ctx.lineWidth = 1 + function drawStaves(l: Layout) { + const { h } = size + ctx!.lineCap = "butt" + ctx!.lineWidth = 1 + ctx!.strokeStyle = rgba(rgb, dark ? 0.2 : 0.24) for (const staff of [0, 1] as const) { - for (let line = 0; line < 5; line++) { - const x = pitchX(staff, line * 2, gap, sp) - const a = project(x, staffY0) - const b = project(x, staffY1) - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() + for (let p = 0; p <= 8; p += 2) { + const x = Math.round(xOf(l, staff, p)) + 0.5 + line(x, 0, x, h) } } - - ctx.lineCap = "round" - ctx.lineJoin = "round" - + // Bar lines every four beats, through both staves like a grand staff. for (let bar = 0; bar < LOOP_BEATS; bar += 4) { - if (!inView(bar)) continue - const d = wrapDelta(bar, beat, LOOP_BEATS) - const y = timeY(d, ppb, playY) - const alpha = 0.14 + nearPlay(d) * 0.2 - for (const staff of [0, 1] as const) { - const a = project(pitchX(staff, 0, gap, sp), y) - const b = project(pitchX(staff, 8, gap, sp), y) - ctx.strokeStyle = rgba(color, alpha) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() - } + const d = wrapDelta(bar, beat) + const y = yOf(l, d) + if (y < -4 || y > h + 4) continue + ctx!.strokeStyle = rgba(rgb, (dark ? 0.16 : 0.2) + litAt(d) * 0.3) + line(xOf(l, 0, 0), y, xOf(l, 1, 8), y) } + } - ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, "Times New Roman", serif` - ctx.textBaseline = "middle" - for (const mark of score.marks) { - if (!inView(mark.t)) continue - const d = wrapDelta(mark.t, beat, LOOP_BEATS) - const pt = project( - pitchX(mark.staff, mark.pitch, gap, sp), - timeY(d, ppb, playY) - ) - ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45) - ctx.fillText(mark.text, pt.x, pt.y) + function drawScore(s: Score, l: Layout, opacity: number, bloom: number) { + if (opacity <= 0) return + const { h } = size + const minD = (l.playY - h - l.ppb) / l.ppb + const maxD = (l.playY + l.ppb) / l.ppb + const inView = (d: number) => d >= minD && d <= maxD + const base = (d: number) => + d >= 0 ? (dark ? 0.46 : 0.58) : dark ? 0.22 : 0.3 + const alphaAt = (d: number) => { + const b = base(d) + return (b + (1 - b) * litAt(d)) * opacity } - - for (const pin of score.hairpins) { - const t1 = pin.t + pin.dur - if (!inView(pin.t) && !inView(t1)) continue - const d0 = wrapDelta(pin.t, beat, LOOP_BEATS) - const d1 = wrapDelta(t1, beat, LOOP_BEATS) - const staff = pin.staff - const outer = staff === 0 ? -4.6 : 12.6 - const inner = staff === 0 ? -3.4 : 11.4 - const y0 = timeY(d0, ppb, playY) - const y1 = timeY(d1, ppb, playY) - const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp) - const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp) - const xMid = pitchX(staff, (outer + inner) / 2, gap, sp) - const a = project(xOuter0, y0) - const b = project(xOuter1, y1) - const c = project(xMid, y0) - const e = project(xMid, y1) - ctx.strokeStyle = rgba(color, 0.22) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.moveTo(c.x, c.y) - ctx.lineTo(e.x, e.y) - ctx.stroke() + const sp = l.sp + const rx = sp * 0.5 + const ry = sp * 0.64 + + ctx!.lineCap = "butt" + + // Dynamics and hairpins share the space between the staves. + ctx!.font = `italic ${Math.round(clamp(sp * 1.3, 10, 17))}px Georgia, "Times New Roman", serif` + ctx!.textAlign = "center" + ctx!.textBaseline = "middle" + for (const mark of s.marks) { + const d = wrapDelta(mark.t, beat) + if (!inView(d)) continue + ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85) + ctx!.fillText(mark.text, l.cx, yOf(l, d) + sp * 0.9) } - - for (const slur of score.slurs) { - if (!inView(slur.t0) && !inView(slur.t1)) continue - const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS) - const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS) - const side = slur.staff === 0 ? -1.8 : 1.8 - const a = project( - pitchX(slur.staff, slur.p0 + side, gap, sp), - timeY(d0, ppb, playY) - ) - const b = project( - pitchX(slur.staff, slur.p1 + side, gap, sp), - timeY(d1, ppb, playY) + ctx!.lineWidth = 1 + for (const pin of s.hairpins) { + const d0 = wrapDelta(pin.t, beat) + const d1 = d0 + pin.dur + if (!inView(d0) && !inView(d1)) continue + const narrow = pin.opening ? d0 : d1 + const wide = pin.opening ? d1 : d0 + const half = sp * 0.9 + ctx!.strokeStyle = rgba( + rgb, + alphaAt(Math.min(Math.abs(d0), Math.abs(d1))) * 0.6 ) - const midT = (slur.t0 + slur.t1) / 2 - const midP = (slur.p0 + slur.p1) / 2 + side * 1.6 - const c = project( - pitchX(slur.staff, midP, gap, sp), - timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY) - ) - ctx.strokeStyle = rgba(color, 0.2) - ctx.lineWidth = 1.1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.quadraticCurveTo(c.x, c.y, b.x, b.y) - ctx.stroke() + ctx!.beginPath() + ctx!.moveTo(l.cx - half, yOf(l, wide)) + ctx!.lineTo(l.cx, yOf(l, narrow)) + ctx!.lineTo(l.cx + half, yOf(l, wide)) + ctx!.stroke() } - for (const link of score.links) { - if (!inView(link.t)) continue - const d = wrapDelta(link.t, beat, LOOP_BEATS) - const y = timeY(d, ppb, playY) - const a = project(pitchX(0, link.p0, gap, sp), y) - const b = project(pitchX(1, link.p1, gap, sp), y) - ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() + // Slurs curve on the inner side, opposite the stems. + ctx!.lineWidth = Math.max(1, sp * 0.1) + for (const slur of s.slurs) { + const d0 = wrapDelta(slur.first.t, beat) + const d1 = d0 + (slur.last.t - slur.first.t) + if (!inView(d0) && !inView(d1)) continue + const inward = slur.staff === 0 ? 1 : -1 + const y0 = yOf(l, d0) + const y1 = yOf(l, d1) + const x0 = xOf(l, slur.staff, slur.first.pitch + inward * 2) + const x1 = xOf(l, slur.staff, slur.last.pitch + inward * 2) + const edge = + inward > 0 + ? Math.max(slur.first.pitch, slur.last.pitch) + 3.5 + : Math.min(slur.first.pitch, slur.last.pitch) - 3.5 + ctx!.strokeStyle = rgba(rgb, alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5) + ctx!.beginPath() + ctx!.moveTo(x0, y0) + ctx!.quadraticCurveTo(xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1) + ctx!.stroke() } - const groups = new Map() - for (const note of score.notes) { - if (note.beam <= 0) continue - if (!inView(note.t)) continue - const list = groups.get(note.beam) - if (list) list.push(note) - else groups.set(note.beam, [note]) - } - - const stemTip = (note: Note, d: number) => { - const dir = note.staff === 0 ? -1 : 1 - const y = timeY(d, ppb, playY) - const x0 = pitchX(note.staff, note.pitch, gap, sp) - const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp) - return { - head: project(x0, y), - tip: project(x1, y), - d, + // Stems and beams. A stem runs along the pitch axis from the side of its + // head (the time axis is "across" here), out to the beam. + const stemW = Math.max(1, sp * 0.11) + const side = -ry * 0.8 + for (const stem of s.stems) { + const d0 = wrapDelta(stem.t0, beat) + const d1 = d0 + (stem.t1 - stem.t0) + if (!inView(d0) && !inView(d1)) continue + const out = stem.staff === 0 ? -1 : 1 + const tipX = xOf(l, stem.staff, stem.tip) + ctx!.lineWidth = stemW + if (stem.chord) { + // One stem through the chord, from the innermost head out to the tip. + const inner = + out < 0 + ? Math.max(...stem.notes.map((n) => n.pitch)) + : Math.min(...stem.notes.map((n) => n.pitch)) + const y = yOf(l, d0) + side + ctx!.strokeStyle = rgba(rgb, alphaAt(d0)) + line(xOf(l, stem.staff, inner), y, tipX, y) + continue } - } - - for (const note of score.notes) { - if (!inView(note.t)) continue - const d = wrapDelta(note.t, beat, LOOP_BEATS) - const { head, tip } = stemTip(note, d) - const intens = nearPlay(d) - ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45) - ctx.lineWidth = 1 * head.s - ctx.beginPath() - ctx.moveTo(head.x, head.y) - ctx.lineTo(tip.x, tip.y) - ctx.stroke() - } - - for (const [, groupNotes] of groups) { - if (groupNotes.length < 2) continue - groupNotes.sort((a, b) => a.t - b.t) - const first = groupNotes[0]! - const last = groupNotes[groupNotes.length - 1]! - const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS)) - const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS)) - const intens = Math.max(nearPlay(a.d), nearPlay(b.d)) - ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5) - ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5) - ctx.beginPath() - ctx.moveTo(a.tip.x, a.tip.y) - ctx.lineTo(b.tip.x, b.tip.y) - ctx.stroke() - if (first.dur <= 0.13) { - const oy = (first.staff === 0 ? -1 : 1) * 3.2 - ctx.beginPath() - ctx.moveTo(a.tip.x, a.tip.y + oy) - ctx.lineTo(b.tip.x, b.tip.y + oy) - ctx.stroke() + for (const n of stem.notes) { + const d = d0 + (n.t - stem.t0) + const y = yOf(l, d) + side + ctx!.strokeStyle = rgba(rgb, alphaAt(d)) + line(xOf(l, stem.staff, n.pitch), y, tipX, y) } - } - - const playYScreen = project(0, playY).y - ctx.save() - ctx.lineCap = "butt" - ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom)) - ctx.lineWidth = 2 - ctx.beginPath() - ctx.moveTo(0, playYScreen) - ctx.lineTo(w, playYScreen) - ctx.stroke() - ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom)) - ctx.lineWidth = 0.75 - ctx.beginPath() - ctx.moveTo(0, playYScreen) - ctx.lineTo(w, playYScreen) - ctx.stroke() - ctx.restore() - - const bloomOp = dark ? "lighter" : "source-over" - ctx.globalCompositeOperation = bloomOp - - for (const note of score.notes) { - const d = wrapDelta(note.t, beat, LOOP_BEATS) - if (d > visMax || d < visMin) continue - const y = timeY(d, ppb, playY) - const x = pitchX(note.staff, note.pitch, gap, sp) - const pt = project(x, y) - const intens = nearPlay(d) - const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2 - const headR = Math.max(2.4, sp * 0.38) - const alpha = upcoming + intens * 0.8 - - if (note.acc !== 0 && d > -0.4) { - const accPt = project(x, y - sp * 0.9) - ctx.fillStyle = rgba(color, 0.25 + intens * 0.5) - ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif` - ctx.fillText(note.acc > 0 ? "#" : "b", accPt.x - 4, accPt.y) + if (stem.beams === 0) continue + const nearest = d1 < 0 ? d1 : d0 > 0 ? d0 : 0 + ctx!.strokeStyle = rgba(rgb, alphaAt(nearest)) + ctx!.lineWidth = sp * 0.36 + const yA = yOf(l, d0) + side + stemW / 2 + const yB = yOf(l, d1) + side - stemW / 2 + const offset = sp * 0.18 * out + line(tipX - offset, yA, tipX - offset, yB) + if (stem.beams === 2) { + const x2 = tipX - offset - out * sp * 0.75 + line(x2, yA, x2, yB) } + } - if (note.pitch < 0 || note.pitch > 8) { - const ledgerPitch = note.pitch < 0 ? -2 : 10 - const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp) - const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp) - const la = project(lx0, y) - const lb = project(lx1, y) - ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(la.x, la.y) - ctx.lineTo(lb.x, lb.y) - ctx.stroke() + // Ledger lines, parallel to the staff lines. + ctx!.lineWidth = 1 + for (const n of s.notes) { + if (n.pitch > -2 && n.pitch < 10) continue + const d = wrapDelta(n.t, beat) + if (!inView(d)) continue + const y = yOf(l, d) + ctx!.strokeStyle = rgba(rgb, alphaAt(d) * 0.7) + const from = n.pitch < 0 ? -2 : 10 + const step = n.pitch < 0 ? -2 : 2 + for (let p = from; step < 0 ? p >= n.pitch : p <= n.pitch; p += step) { + const x = xOf(l, n.staff, p) + line(x, y - ry * 1.7, x, y + ry * 1.7) } + } - if (intens > 0.04 && bloom > 0) { - const halo = (5 + intens * 9) * bloom - glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom)) + // Glow under the heads that are playing. + if (bloom > 0) { + ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" + for (const n of s.notes) { + const d = wrapDelta(n.t, beat) + const lit = litAt(d) + if (lit < 0.03 || !inView(d)) continue + const r = sp * (1.1 + lit * 1.5) * bloom + ctx!.globalAlpha = + lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity + ctx!.drawImage( + glowSprite, + xOf(l, n.staff, n.pitch) - r, + yOf(l, d) - r, + r * 2, + r * 2 + ) } + ctx!.globalAlpha = 1 + ctx!.globalCompositeOperation = "source-over" + } - ctx.globalCompositeOperation = "source-over" - ctx.save() - if (intens > 0.2 && bloom > 0) { - ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45) - ctx.shadowBlur = (3.5 + intens * 5) * bloom + // Noteheads, with accidentals just before them in reading order (below). + ctx!.font = `${Math.round(sp * 1.5)}px Georgia, "Times New Roman", serif` + for (const n of s.notes) { + const d = wrapDelta(n.t, beat) + if (!inView(d)) continue + const x = xOf(l, n.staff, n.pitch) + const y = yOf(l, d) + const color = rgba(rgb, alphaAt(d)) + ctx!.beginPath() + ctx!.ellipse(x, y, rx, ry, 0.35, 0, Math.PI * 2) + if (n.hollow) { + ctx!.strokeStyle = color + ctx!.lineWidth = Math.max(1.2, sp * 0.16) + ctx!.stroke() + } else { + ctx!.fillStyle = color + ctx!.fill() } - drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1))) - ctx.restore() - ctx.globalCompositeOperation = bloomOp - - if (d <= 0 && d > -0.08 && intens > 0.7) { - const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0 - if (!hit.has(id)) { - hit.add(id) - flares.push({ - x, - y: playY, - age: 0, - life: 0.38, - size: 10, - }) - if (hit.size > 400) hit.clear() - } + if (n.acc !== 0) { + ctx!.fillStyle = color + ctx!.fillText(n.acc > 0 ? "♯" : "♭", x, y + ry + sp * 0.95) } } + } - for (let i = flares.length - 1; i >= 0; i--) { - const flare = flares[i]! - flare.age += dt - const t = flare.age / flare.life - if (t >= 1) { - flares.splice(i, 1) - continue - } - const pt = project(flare.x, flare.y) - const fade = Math.sin((1 - t) * Math.PI) - glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade) - ctx.save() - ctx.translate(pt.x, pt.y) - ctx.rotate(cam.z) - const flareW = 22 * bloom * (1 + t * 0.4) * pt.s - const flareH = 1.4 * pt.s - const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0) - fg.addColorStop(0, rgba(color, 0)) - fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade)) - fg.addColorStop(1, rgba(color, 0)) - ctx.fillStyle = fg - ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2) - ctx.restore() + function drawPlayhead(l: Layout, bloom: number) { + const { w } = size + const y = Math.round(l.playY) + 0.5 + if (bloom > 0) { + ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" + ctx!.globalAlpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom) + const band = l.sp * 1.4 * Math.max(0.6, bloom) + ctx!.drawImage(glowSprite, 0, y - band, w, band * 2) + ctx!.globalAlpha = 1 + ctx!.globalCompositeOperation = "source-over" } + ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55) + ctx!.lineWidth = 1 + line(0, y, w, y) + } - ctx.globalCompositeOperation = "source-over" + function drawFlares(l: Layout, bloom: number) { + if (flares.length === 0 || bloom <= 0) return + ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" + for (const f of flares) { + const k = f.age / FLARE_LIFE + const fade = (1 - k) * (1 - k) + const x = xOf(l, f.staff, f.pitch) + const r = l.sp * (1.6 + k * 1.4) * bloom + ctx!.globalAlpha = fade * (dark ? 0.8 : 0.4) + ctx!.drawImage(glowSprite, x - r, l.playY - r, r * 2, r * 2) + // A streak along the playhead. + const streak = l.sp * (2.5 + k * 3) * bloom + ctx!.globalAlpha = fade * (dark ? 0.9 : 0.5) + ctx!.drawImage(glowSprite, x - streak, l.playY - 1.5, streak * 2, 3) + } + ctx!.globalAlpha = 1 + ctx!.globalCompositeOperation = "source-over" + } - raf = requestAnimationFrame(frame) + /** Top and bottom fade into the background — in the canvas, so it costs nothing and blurs nothing. */ + function drawEdgeFade(w: number, h: number) { + const f = h * EDGE_FADE + const solid = dark ? "rgba(5,5,5,1)" : "rgba(0,0,0,1)" + const clear = dark ? "rgba(5,5,5,0)" : "rgba(0,0,0,0)" + ctx!.globalCompositeOperation = dark ? "source-over" : "destination-out" + const top = ctx!.createLinearGradient(0, 0, 0, f) + top.addColorStop(0, solid) + top.addColorStop(1, clear) + ctx!.fillStyle = top + ctx!.fillRect(0, 0, w, f) + const bottom = ctx!.createLinearGradient(0, h - f, 0, h) + bottom.addColorStop(0, clear) + bottom.addColorStop(1, solid) + ctx!.fillStyle = bottom + ctx!.fillRect(0, h - f, w, f) + ctx!.globalCompositeOperation = "source-over" } - raf = requestAnimationFrame(frame) + resize() + wake() return { setOptions(next) { const prevSeed = seedOf(options.seed) - const prevDens = densityOf(options.density) + const prevDensity = densityOf(options.density) + const prevColor = options.color options = { ...options, ...next, @@ -878,19 +880,28 @@ export function createPhosphorScore( theme: themeOf(next.theme ?? options.theme), } syncTheme() - rgb = parseColor(resolveColor(options.color, dark)) + if (options.color !== prevColor) recolor() if ( seedOf(options.seed) !== prevSeed || - densityOf(options.density) !== prevDens + densityOf(options.density) !== prevDensity ) { - rebuild() + // Crossfade from the old score instead of cutting. + previous = reduce ? null : score + rebuildAge = reduce ? REBUILD_FADE : 0 + score = generateScore(seedOf(options.seed), densityOf(options.density)) + dust = makeDust(seedOf(options.seed)) + flares.length = 0 } + wake() }, destroy() { running = false cancelAnimationFrame(raf) + raf = 0 ro.disconnect() + io.disconnect() mo.disconnect() + document.removeEventListener("visibilitychange", onVisibility) mqReduce.removeEventListener("change", onReduce) mqDark.removeEventListener("change", syncTheme) }, diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index 3dba72e..af42847 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -5,20 +5,15 @@ import { onMount } from "svelte" import { createPhosphorScore, - DARK_BG, DEFAULT_DENSITY, DEFAULT_GLOW, DEFAULT_SEED, DEFAULT_SPEED, - resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, + type PhosphorScoreTheme, } from "./phosphor-score-vanilla" - function cn(...parts: Array) { - return parts.filter(Boolean).join(" ") - } - interface Props extends Omit { class?: string } @@ -34,12 +29,20 @@ theme = "auto", }: Props = $props() + /** + * Background before the first frame, so dark mode never flashes light. + * `#050505` is `DARK_BG`; Tailwind needs the literal. + */ + const SURFACE: Record = { + auto: "bg-background dark:bg-[#050505]", + dark: "bg-[#050505]", + light: "bg-background", + } + let canvas: HTMLCanvasElement | undefined = $state() - let isDark = $state(false) let instance: PhosphorScoreInstance | null = null onMount(() => { - isDark = resolveDark(theme) if (!canvas) return instance = createPhosphorScore(canvas, { color, @@ -49,9 +52,6 @@ sway, seed, theme, - onThemeChange: (dark) => { - isDark = dark - }, }) return () => { instance?.destroy() @@ -76,44 +76,13 @@ data-slot="phosphor-score" role="img" aria-label="Falling phosphor sheet music" - class={cn("absolute inset-0 overflow-hidden bg-background", className)} - style={isDark ? `background-color: ${DARK_BG}` : ""} + class={[ + "absolute inset-0 overflow-hidden", + SURFACE[theme] ?? SURFACE.auto, + className, + ] + .filter(Boolean) + .join(" ")} > - - - - - -
diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index b5b86e9..d312478 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -1,19 +1,18 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { useEffect, useRef } from "react" import { cn } from "@/lib/utils" import { createPhosphorScore, - DARK_BG, DEFAULT_DENSITY, DEFAULT_GLOW, DEFAULT_SEED, DEFAULT_SPEED, - resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, + type PhosphorScoreTheme, } from "./phosphor-score-vanilla" export { @@ -39,63 +38,20 @@ export type PhosphorScoreProps = Omit & { className?: string } -function PhosphorEdgeFade({ - edge, - dark, -}: { - edge: "top" | "bottom" - dark: boolean -}) { - const isTop = edge === "top" - const pos = isTop ? "top-0" : "bottom-0" - const mask = isTop - ? "mask-[linear-gradient(to_bottom,black,transparent)]" - : "mask-[linear-gradient(to_top,black,transparent)]" - return ( - <> -
-
-
- - ) +/** + * Background before the first frame, so dark mode never flashes light. + * `#050505` is `DARK_BG`; Tailwind needs the literal. + */ +const SURFACE: Record = { + auto: "bg-background dark:bg-[#050505]", + dark: "bg-[#050505]", + light: "bg-background", } /** - * Vertical phosphor sheet music — notes fall toward a playhead, bloom, - * then exit in a flare. Follows light and dark. + * Vertical phosphor sheet music — a grand staff scrolls down to a playhead, + * each note blooms and flares as it plays, then fades with an afterglow. + * Follows light and dark. */ export function PhosphorScore({ className, @@ -109,23 +65,6 @@ export function PhosphorScore({ }: PhosphorScoreProps) { const canvasRef = useRef(null) const instanceRef = useRef(null) - const [isDark, setIsDark] = useState(false) - - useEffect(() => { - const sync = () => setIsDark(resolveDark(theme)) - sync() - const mo = new MutationObserver(sync) - mo.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "data-theme"], - }) - const mq = window.matchMedia("(prefers-color-scheme: dark)") - mq.addEventListener("change", sync) - return () => { - mo.disconnect() - mq.removeEventListener("change", sync) - } - }, [theme]) useEffect(() => { const canvas = canvasRef.current @@ -138,7 +77,6 @@ export function PhosphorScore({ sway, seed, theme, - onThemeChange: setIsDark, }) return () => { instanceRef.current?.destroy() @@ -157,7 +95,6 @@ export function PhosphorScore({ sway, seed, theme, - onThemeChange: setIsDark, }) }, [color, glow, speed, density, sway, seed, theme]) @@ -166,12 +103,13 @@ export function PhosphorScore({ data-slot="phosphor-score" role="img" aria-label="Falling phosphor sheet music" - className={cn("absolute inset-0 overflow-hidden bg-background", className)} - style={isDark ? { backgroundColor: DARK_BG } : undefined} + className={cn( + "absolute inset-0 overflow-hidden", + SURFACE[theme] ?? SURFACE.auto, + className + )} > - -
) } From a1e36969c68a08247ad217071928c3f4d1905035 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:14:26 +0530 Subject: [PATCH 12/49] Let Phosphor Score tilt and spin with rotateX, rotateY and rotateZ. The engine projects the score plane in perspective about the playhead, fades it into the distance, and repeats the loop at steep tilts. All three default to 0, which draws exactly as before. The demo gets three sliders and the docs a viewing-angle example and props rows. --- content/docs/components/phosphor-score.mdx | 42 +- public/r/phosphor-score-svelte.json | 2 +- public/r/phosphor-score.json | 2 +- .../phosphor-score/phosphor-score-demo.tsx | 33 + .../phosphor-score/phosphor-score-vanilla.ts | 574 +++++++++++++----- registry/phosphor-score/phosphor-score.svelte | 9 + registry/phosphor-score/phosphor-score.tsx | 22 +- 7 files changed, 530 insertions(+), 154 deletions(-) diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx index a54da2a..23627f1 100644 --- a/content/docs/components/phosphor-score.mdx +++ b/content/docs/components/phosphor-score.mdx @@ -69,20 +69,40 @@ Lock a theme or push the bloom. Omit `color` to follow light / dark. +Change the viewing angle. The plane turns about the playhead in perspective, like CSS `rotateX()` `rotateY()` `rotateZ()`, and fades out as it recedes. + + + + ```tsx + + + ``` + + + ```svelte + + + ``` + + + `prefers-reduced-motion: reduce` holds a still frame of the score and stops drawing. The animation also pauses while the canvas is off-screen or the tab is hidden. ## Props -| Prop | Type | Default | -| ----------- | ----------------------------- | ----------------------------------------------- | -| `color` | `string` | theme phosphor / forest ink — any CSS color | -| `glow` | `number` | `50` — 0–100, halo size at the playhead | -| `speed` | `number` | `1.35` — beats per second | -| `density` | `number` | `1` — `0.35`–`1.85`, how packed the notation is | -| `sway` | `boolean` | `true` — slow camera drift | -| `seed` | `number` | `23` — a new seed crossfades to a new score | -| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| Prop | Type | Default | +| ----------- | ----------------------------- | ------------------------------------------------ | +| `color` | `string` | theme phosphor / forest ink — any CSS color | +| `glow` | `number` | `50` — 0–100, halo size at the playhead | +| `speed` | `number` | `1.35` — beats per second | +| `density` | `number` | `1` — `0.35`–`1.85`, how packed the notation is | +| `sway` | `boolean` | `true` — slow camera drift | +| `seed` | `number` | `23` — a new seed crossfades to a new score | +| `rotateX` | `number` | `0` — degrees, `-70`–`70`, tips the top away | +| `rotateY` | `number` | `0` — degrees, `-70`–`70`, swings the right away | +| `rotateZ` | `number` | `0` — degrees, spins the plane clockwise | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | Omit `color` and `theme="auto"` picks phosphor green on dark, forest ink on light. The canvas is transparent in light mode so the parent background shows through. diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index 10b080e..e9358c2 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 3a40724..c1ef102 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 * 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\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-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\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\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 * 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\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 if (dark) {\n ctx!.fillStyle = DARK_BG\n ctx!.fillRect(0, 0, w, h)\n } else {\n ctx!.clearRect(0, 0, w, h)\n }\n if (w < 2 || h < 2) return\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 function line(x0: number, y0: number, x1: number, y1: number) {\n ctx!.beginPath()\n ctx!.moveTo(x0, y0)\n ctx!.lineTo(x1, y1)\n ctx!.stroke()\n }\n\n function drawStaves(l: Layout) {\n const { h } = size\n ctx!.lineCap = \"butt\"\n ctx!.lineWidth = 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, 0, x, h)\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 const d = wrapDelta(bar, beat)\n const y = yOf(l, d)\n if (y < -4 || y > h + 4) continue\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 function drawScore(s: Score, l: Layout, opacity: number, bloom: number) {\n if (opacity <= 0) return\n const { h } = size\n const minD = (l.playY - h - l.ppb) / l.ppb\n const maxD = (l.playY + l.ppb) / l.ppb\n const inView = (d: number) => d >= minD && d <= maxD\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 const d = wrapDelta(mark.t, beat)\n if (!inView(d)) continue\n ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85)\n ctx!.fillText(mark.text, l.cx, yOf(l, d) + sp * 0.9)\n }\n ctx!.lineWidth = 1\n for (const pin of s.hairpins) {\n const d0 = wrapDelta(pin.t, beat)\n const d1 = d0 + pin.dur\n if (!inView(d0) && !inView(d1)) continue\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 ctx!.beginPath()\n ctx!.moveTo(l.cx - half, yOf(l, wide))\n ctx!.lineTo(l.cx, yOf(l, narrow))\n ctx!.lineTo(l.cx + half, yOf(l, wide))\n ctx!.stroke()\n }\n\n // Slurs curve on the inner side, opposite the stems.\n ctx!.lineWidth = Math.max(1, sp * 0.1)\n for (const slur of s.slurs) {\n const d0 = wrapDelta(slur.first.t, beat)\n const d1 = d0 + (slur.last.t - slur.first.t)\n if (!inView(d0) && !inView(d1)) continue\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(rgb, alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5)\n ctx!.beginPath()\n ctx!.moveTo(x0, y0)\n ctx!.quadraticCurveTo(xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1)\n ctx!.stroke()\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 d0 = wrapDelta(stem.t0, beat)\n const d1 = d0 + (stem.t1 - stem.t0)\n if (!inView(d0) && !inView(d1)) continue\n const out = stem.staff === 0 ? -1 : 1\n const tipX = xOf(l, stem.staff, stem.tip)\n ctx!.lineWidth = 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 ctx!.lineWidth = 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 // Ledger lines, parallel to the staff lines.\n ctx!.lineWidth = 1\n for (const n of s.notes) {\n if (n.pitch > -2 && n.pitch < 10) continue\n const d = wrapDelta(n.t, beat)\n if (!inView(d)) continue\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 // 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 const d = wrapDelta(n.t, beat)\n const lit = litAt(d)\n if (lit < 0.03 || !inView(d)) continue\n const r = sp * (1.1 + lit * 1.5) * bloom\n ctx!.globalAlpha =\n lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity\n ctx!.drawImage(\n glowSprite,\n xOf(l, n.staff, n.pitch) - r,\n yOf(l, d) - r,\n r * 2,\n r * 2\n )\n }\n ctx!.globalAlpha = 1\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 const d = wrapDelta(n.t, beat)\n if (!inView(d)) continue\n const x = xOf(l, n.staff, n.pitch)\n const y = yOf(l, d)\n const color = rgba(rgb, alphaAt(d))\n ctx!.beginPath()\n ctx!.ellipse(x, y, 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 ? \"♯\" : \"♭\", x, y + ry + sp * 0.95)\n }\n }\n }\n\n function drawPlayhead(l: Layout, bloom: number) {\n const { w } = size\n const y = Math.round(l.playY) + 0.5\n if (bloom > 0) {\n ctx!.globalCompositeOperation = dark ? \"lighter\" : \"source-over\"\n ctx!.globalAlpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom)\n const band = l.sp * 1.4 * Math.max(0.6, bloom)\n ctx!.drawImage(glowSprite, 0, y - band, w, band * 2)\n ctx!.globalAlpha = 1\n ctx!.globalCompositeOperation = \"source-over\"\n }\n ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55)\n ctx!.lineWidth = 1\n line(0, y, w, y)\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 ctx!.globalAlpha = fade * (dark ? 0.8 : 0.4)\n ctx!.drawImage(glowSprite, x - r, l.playY - r, r * 2, r * 2)\n // A streak along the playhead.\n const streak = l.sp * (2.5 + k * 3) * bloom\n ctx!.globalAlpha = fade * (dark ? 0.9 : 0.5)\n ctx!.drawImage(glowSprite, x - streak, l.playY - 1.5, streak * 2, 3)\n }\n ctx!.globalAlpha = 1\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 const solid = dark ? \"rgba(5,5,5,1)\" : \"rgba(0,0,0,1)\"\n const clear = dark ? \"rgba(5,5,5,0)\" : \"rgba(0,0,0,0)\"\n ctx!.globalCompositeOperation = dark ? \"source-over\" : \"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 * Background before the first frame, so dark mode never flashes light.\n * `#050505` is `DARK_BG`; Tailwind needs the literal.\n */\nconst SURFACE: Record = {\n auto: \"bg-background dark:bg-[#050505]\",\n dark: \"bg-[#050505]\",\n light: \"bg-background\",\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 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 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 theme,\n })\n }, [color, glow, speed, density, sway, seed, theme])\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\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-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 if (dark) {\n ctx!.fillStyle = DARK_BG\n ctx!.fillRect(0, 0, w, h)\n } else {\n ctx!.clearRect(0, 0, w, h)\n }\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 const solid = dark ? \"rgba(5,5,5,1)\" : \"rgba(0,0,0,1)\"\n const clear = dark ? \"rgba(5,5,5,0)\" : \"rgba(0,0,0,0)\"\n ctx!.globalCompositeOperation = dark ? \"source-over\" : \"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 * Background before the first frame, so dark mode never flashes light.\n * `#050505` is `DARK_BG`; Tailwind needs the literal.\n */\nconst SURFACE: Record = {\n auto: \"bg-background dark:bg-[#050505]\",\n dark: \"bg-[#050505]\",\n light: \"bg-background\",\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/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx index b078fbd..f0fd517 100644 --- a/registry/phosphor-score/phosphor-score-demo.tsx +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -40,6 +40,9 @@ export function PhosphorScoreDemo() { speed: DEFAULT_SPEED, density: DEFAULT_DENSITY, seed: DEFAULT_SEED, + rotateX: 0, + rotateY: 0, + rotateZ: 0, sway: true, }), [stock] @@ -71,6 +74,9 @@ export function PhosphorScoreDemo() { speed={props.speed} density={props.density} seed={props.seed} + rotateX={props.rotateX} + rotateY={props.rotateY} + rotateZ={props.rotateZ} sway={props.sway} theme="auto" /> @@ -88,6 +94,9 @@ export function PhosphorScoreDemo() { density: props.density === DEFAULT_DENSITY ? undefined : props.density, seed: props.seed === DEFAULT_SEED ? undefined : props.seed, + rotateX: props.rotateX || undefined, + rotateY: props.rotateY || undefined, + rotateZ: props.rotateZ || undefined, sway: props.sway ? undefined : false, }} > @@ -128,6 +137,30 @@ export function PhosphorScoreDemo() { step={1} onChange={(v) => updateProp("seed", v)} /> + updateProp("rotateX", v)} + /> + updateProp("rotateY", v)} + /> + updateProp("rotateZ", v)} + /> const seedOf = (value?: number) => Math.round(numberOr(value, DEFAULT_SEED, 0, 2 ** 32 - 1)) >>> 0 +const tiltOf = (value?: number) => numberOr(value, 0, -MAX_TILT, MAX_TILT) +const spinOf = (value?: number) => { + const deg = numberOr(value, 0, -Infinity, Infinity) + return ((((deg + 180) % 360) + 360) % 360) - 180 +} + function themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme { return value === "light" || value === "dark" || value === "auto" ? value @@ -393,6 +419,112 @@ function litAt(d: number) { return Math.exp(d * 2.4) } +/** + * How the score plane sits in front of the camera. Plane coordinates are + * the unrotated canvas pixels; it turns about the playhead, which is also + * where perspective converges. + */ +type View = { + /** `rotateX · rotateY · rotateZ`, row-major, CSS axes (y down, z toward you). */ + m: number[] + ox: number + oy: number + /** Camera distance. */ + cam: number + /** No depth variation across the plane, so nothing foreshortens or fogs. */ + depthless: boolean + /** Bounds of the plane that are on screen and not lost in the distance. */ + x0: number + x1: number + y0: number + y1: number +} + +function makeView( + w: number, + h: number, + rotateX: number, + rotateY: number, + rotateZ: number +): View { + const rad = Math.PI / 180 + const [cx, sx] = [Math.cos(rotateX * rad), Math.sin(rotateX * rad)] + const [cy, sy] = [Math.cos(rotateY * rad), Math.sin(rotateY * rad)] + const [cz, sz] = [Math.cos(rotateZ * rad), Math.sin(rotateZ * rad)] + // Rx · Ry · Rz, the order CSS applies `rotateX() rotateY() rotateZ()`. + const m = [ + cy * cz, + -cy * sz, + sy, + sx * sy * cz + cx * sz, + -sx * sy * sz + cx * cz, + -sx * cy, + -cx * sy * cz + sx * sz, + cx * sy * sz + sx * cz, + cx * cy, + ] + const ox = w / 2 + const oy = h * PLAYHEAD + const cam = Math.max(w, h) * PERSPECTIVE + const view: View = { + m, + ox, + oy, + cam, + depthless: Math.abs(m[6]!) + Math.abs(m[7]!) < 1e-6, + x0: 0, + x1: w, + y0: 0, + y1: h, + } + if (rotateX === 0 && rotateY === 0 && rotateZ === 0) return view + + // The screen, less whatever lies beyond FAR_SCALE, traced back onto the + // plane. Along a ray through screen point (u, v) the plane is hit at + // t = m22·cam / (m22·cam − m02·u − m12·v), with depth scale 1 / t — so + // "not too far" is a straight line on screen and clips the rectangle. + const [m02, m12, m22] = [m[2]!, m[5]!, m[8]!] + const far = (u: number, v: number) => + m22 * cam * (1 - FAR_SCALE) - m02 * u - m12 * v + const corners: [number, number][] = [ + [-ox, -oy], + [w - ox, -oy], + [w - ox, h - oy], + [-ox, h - oy], + ] + const kept: [number, number][] = [] + corners.forEach((a, i) => { + const b = corners[(i + 1) % corners.length]! + const fa = far(a[0], a[1]) + const fb = far(b[0], b[1]) + if (fa >= 0) kept.push(a) + if (fa >= 0 !== fb >= 0) { + const k = fa / (fa - fb) + kept.push([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]) + } + }) + + view.x0 = view.y0 = Infinity + view.x1 = view.y1 = -Infinity + for (const [u, v] of kept) { + const t = (m22 * cam) / (m22 * cam - m02 * u - m12 * v) + const [wx, wy, wz] = [t * u, t * v, cam - t * cam] + const px = m[0]! * wx + m[3]! * wy + m[6]! * wz + ox + const py = m[1]! * wx + m[4]! * wy + m[7]! * wz + oy + view.x0 = Math.min(view.x0, px) + view.x1 = Math.max(view.x1, px) + view.y0 = Math.min(view.y0, py) + view.y1 = Math.max(view.y1, py) + } + return view +} + +/** Fades the plane out as it recedes toward `FAR_SCALE`. */ +function fogOf(scale: number) { + const k = clamp((scale - FAR_SCALE) / FOG_RANGE, 0, 1) + return k * k * (3 - 2 * k) +} + /** * Vertical phosphor sheet music: a grand staff scrolls down toward a * playhead, each note blooms and flares as it plays, then fades with a @@ -445,6 +577,7 @@ export function createPhosphorScore( // Start a few bars in, so the first frame already has notes at the playhead. let beat = 8 let clock = 0 + let view = makeView(1, 1, 0, 0, 0) const resize = () => { const parent = canvas.parentElement @@ -562,6 +695,13 @@ export function createPhosphorScore( } if (w < 2 || h < 2) return + view = makeView( + w, + h, + tiltOf(options.rotateX), + tiltOf(options.rotateY), + spinOf(options.rotateZ) + ) const swayOn = options.sway !== false && !reduce const cam = { x: swayOn ? Math.cos(clock * 0.23) * 10 : 0, @@ -609,40 +749,168 @@ export function createPhosphorScore( l.cx + (staff === 0 ? -l.gap : l.gap) + (pitch - 4) * (l.sp / 2) const yOf = (l: Layout, d: number) => l.playY - d * l.ppb - function line(x0: number, y0: number, x1: number, y1: number) { + /** + * Every beat offset at which something at time `t`, lasting `len` beats, + * is in view. Usually one; a steep tilt can see several loops ahead. + */ + function spots(l: Layout, t: number, len = 0) { + const minD = (l.playY - view.y1 - l.ppb) / l.ppb + const maxD = (l.playY - view.y0 + l.ppb) / l.ppb + const first = wrapDelta(t, beat) + const out: number[] = [] + const from = Math.ceil((minD - len - first) / LOOP_BEATS) + const to = Math.floor((maxD - first) / LOOP_BEATS) + for (let k = from; k <= to; k++) out.push(first + k * LOOP_BEATS) + return out + } + + /** + * Plane point → screen `[x, y, scale]`, where scale is how much the + * perspective enlarges it there. Scale is `0` behind the camera. + */ + function project(x: number, y: number): [number, number, number] { + const { m, ox, oy, cam } = view + const px = x - ox + const py = y - oy + const z = m[6]! * px + m[7]! * py + if (cam - z < cam * 0.02) return [0, 0, 0] + const s = cam / (cam - z) + return [ + ox + (m[0]! * px + m[1]! * py) * s, + oy + (m[3]! * px + m[4]! * py) * s, + s, + ] + } + + /** + * Moves the canvas into the plane at `(x, y)`, so a notehead, glyph or + * glow drawn around the origin tilts and foreshortens with the score. + * Returns the distance fog there; `leave()` restores screen space. + */ + function enter(x: number, y: number) { + const { m, ox, oy, cam } = view + const { dpr } = size + const px = x - ox + const py = y - oy + const qx = m[0]! * px + m[1]! * py + const qy = m[3]! * px + m[4]! * py + const z = m[6]! * px + m[7]! * py + if (cam - z < cam * 0.02) return 0 + const s = cam / (cam - z) + // Jacobian of the projection: how a unit step along the plane lands on screen. + const k = (s * s) / cam + ctx!.setTransform( + dpr * (s * m[0]! + qx * k * m[6]!), + dpr * (s * m[3]! + qy * k * m[6]!), + dpr * (s * m[1]! + qx * k * m[7]!), + dpr * (s * m[4]! + qy * k * m[7]!), + dpr * (ox + qx * s), + dpr * (oy + qy * s) + ) + return fogOf(s) + } + + function leave() { + const { dpr } = size + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx!.globalAlpha = 1 + } + + /** Plane stroke width; `line` and `polyline` scale it with depth. */ + let lw = 1 + + /** + * A straight stroke on the plane. Long ones pass `segments` so the fog + * can thin them as they recede. + */ + function line(x0: number, y0: number, x1: number, y1: number, segments = 1) { + const n = view.depthless ? 1 : segments + for (let i = 0; i < n; i++) { + const a = i / n + const b = (i + 1) / n + polyline([ + [x0 + (x1 - x0) * a, y0 + (y1 - y0) * a], + [x0 + (x1 - x0) * b, y0 + (y1 - y0) * b], + ]) + } + } + + function polyline(points: [number, number][]) { + let scale = 0 ctx!.beginPath() - ctx!.moveTo(x0, y0) - ctx!.lineTo(x1, y1) + for (const [i, [x, y]] of points.entries()) { + const p = project(x, y) + if (p[2] <= 0) return + scale += p[2] + if (i === 0) ctx!.moveTo(p[0], p[1]) + else ctx!.lineTo(p[0], p[1]) + } + scale /= points.length + const fog = fogOf(scale) + if (fog <= 0) return + ctx!.globalAlpha = fog + ctx!.lineWidth = lw * scale ctx!.stroke() + ctx!.globalAlpha = 1 + } + + /** A quadratic curve on the plane; its control point is projected too. */ + function curve( + x0: number, + y0: number, + cx: number, + cy: number, + x1: number, + y1: number + ) { + const a = project(x0, y0) + const c = project(cx, cy) + const b = project(x1, y1) + if (a[2] <= 0 || b[2] <= 0 || c[2] <= 0) return + const scale = (a[2] + b[2]) / 2 + const fog = fogOf(scale) + if (fog <= 0) return + ctx!.globalAlpha = fog + ctx!.lineWidth = lw * scale + ctx!.beginPath() + ctx!.moveTo(a[0], a[1]) + ctx!.quadraticCurveTo(c[0], c[1], b[0], b[1]) + ctx!.stroke() + ctx!.globalAlpha = 1 + } + + /** The glow sprite, stretched to `rx` × `ry` around `(x, y)` on the plane. */ + function glow(x: number, y: number, rx: number, ry: number, alpha: number) { + const fog = enter(x, y) + if (fog > 0) { + ctx!.globalAlpha = alpha * fog + ctx!.drawImage(glowSprite, -rx, -ry, rx * 2, ry * 2) + } + leave() } function drawStaves(l: Layout) { - const { h } = size ctx!.lineCap = "butt" - ctx!.lineWidth = 1 + lw = 1 ctx!.strokeStyle = rgba(rgb, dark ? 0.2 : 0.24) for (const staff of [0, 1] as const) { for (let p = 0; p <= 8; p += 2) { const x = Math.round(xOf(l, staff, p)) + 0.5 - line(x, 0, x, h) + line(x, view.y0, x, view.y1, 16) } } // Bar lines every four beats, through both staves like a grand staff. for (let bar = 0; bar < LOOP_BEATS; bar += 4) { - const d = wrapDelta(bar, beat) - const y = yOf(l, d) - if (y < -4 || y > h + 4) continue - ctx!.strokeStyle = rgba(rgb, (dark ? 0.16 : 0.2) + litAt(d) * 0.3) - line(xOf(l, 0, 0), y, xOf(l, 1, 8), y) + for (const d of spots(l, bar)) { + const y = yOf(l, d) + ctx!.strokeStyle = rgba(rgb, (dark ? 0.16 : 0.2) + litAt(d) * 0.3) + line(xOf(l, 0, 0), y, xOf(l, 1, 8), y) + } } } function drawScore(s: Score, l: Layout, opacity: number, bloom: number) { if (opacity <= 0) return - const { h } = size - const minD = (l.playY - h - l.ppb) / l.ppb - const maxD = (l.playY + l.ppb) / l.ppb - const inView = (d: number) => d >= minD && d <= maxD const base = (d: number) => d >= 0 ? (dark ? 0.46 : 0.58) : dark ? 0.22 : 0.3 const alphaAt = (d: number) => { @@ -660,50 +928,56 @@ export function createPhosphorScore( ctx!.textAlign = "center" ctx!.textBaseline = "middle" for (const mark of s.marks) { - const d = wrapDelta(mark.t, beat) - if (!inView(d)) continue - ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85) - ctx!.fillText(mark.text, l.cx, yOf(l, d) + sp * 0.9) + for (const d of spots(l, mark.t)) { + const fog = enter(l.cx, yOf(l, d) + sp * 0.9) + if (fog > 0) { + ctx!.globalAlpha = fog + ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85) + ctx!.fillText(mark.text, 0, 0) + } + leave() + } } - ctx!.lineWidth = 1 + lw = 1 for (const pin of s.hairpins) { - const d0 = wrapDelta(pin.t, beat) - const d1 = d0 + pin.dur - if (!inView(d0) && !inView(d1)) continue - const narrow = pin.opening ? d0 : d1 - const wide = pin.opening ? d1 : d0 - const half = sp * 0.9 - ctx!.strokeStyle = rgba( - rgb, - alphaAt(Math.min(Math.abs(d0), Math.abs(d1))) * 0.6 - ) - ctx!.beginPath() - ctx!.moveTo(l.cx - half, yOf(l, wide)) - ctx!.lineTo(l.cx, yOf(l, narrow)) - ctx!.lineTo(l.cx + half, yOf(l, wide)) - ctx!.stroke() + for (const d0 of spots(l, pin.t, pin.dur)) { + const d1 = d0 + pin.dur + const narrow = pin.opening ? d0 : d1 + const wide = pin.opening ? d1 : d0 + const half = sp * 0.9 + ctx!.strokeStyle = rgba( + rgb, + alphaAt(Math.min(Math.abs(d0), Math.abs(d1))) * 0.6 + ) + polyline([ + [l.cx - half, yOf(l, wide)], + [l.cx, yOf(l, narrow)], + [l.cx + half, yOf(l, wide)], + ]) + } } // Slurs curve on the inner side, opposite the stems. - ctx!.lineWidth = Math.max(1, sp * 0.1) + lw = Math.max(1, sp * 0.1) for (const slur of s.slurs) { - const d0 = wrapDelta(slur.first.t, beat) - const d1 = d0 + (slur.last.t - slur.first.t) - if (!inView(d0) && !inView(d1)) continue - const inward = slur.staff === 0 ? 1 : -1 - const y0 = yOf(l, d0) - const y1 = yOf(l, d1) - const x0 = xOf(l, slur.staff, slur.first.pitch + inward * 2) - const x1 = xOf(l, slur.staff, slur.last.pitch + inward * 2) - const edge = - inward > 0 - ? Math.max(slur.first.pitch, slur.last.pitch) + 3.5 - : Math.min(slur.first.pitch, slur.last.pitch) - 3.5 - ctx!.strokeStyle = rgba(rgb, alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5) - ctx!.beginPath() - ctx!.moveTo(x0, y0) - ctx!.quadraticCurveTo(xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1) - ctx!.stroke() + const len = slur.last.t - slur.first.t + for (const d0 of spots(l, slur.first.t, len)) { + const d1 = d0 + len + const inward = slur.staff === 0 ? 1 : -1 + const y0 = yOf(l, d0) + const y1 = yOf(l, d1) + const x0 = xOf(l, slur.staff, slur.first.pitch + inward * 2) + const x1 = xOf(l, slur.staff, slur.last.pitch + inward * 2) + const edge = + inward > 0 + ? Math.max(slur.first.pitch, slur.last.pitch) + 3.5 + : Math.min(slur.first.pitch, slur.last.pitch) - 3.5 + ctx!.strokeStyle = rgba( + rgb, + alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5 + ) + curve(x0, y0, xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1) + } } // Stems and beams. A stem runs along the pitch axis from the side of its @@ -711,56 +985,57 @@ export function createPhosphorScore( const stemW = Math.max(1, sp * 0.11) const side = -ry * 0.8 for (const stem of s.stems) { - const d0 = wrapDelta(stem.t0, beat) - const d1 = d0 + (stem.t1 - stem.t0) - if (!inView(d0) && !inView(d1)) continue - const out = stem.staff === 0 ? -1 : 1 - const tipX = xOf(l, stem.staff, stem.tip) - ctx!.lineWidth = stemW - if (stem.chord) { - // One stem through the chord, from the innermost head out to the tip. - const inner = - out < 0 - ? Math.max(...stem.notes.map((n) => n.pitch)) - : Math.min(...stem.notes.map((n) => n.pitch)) - const y = yOf(l, d0) + side - ctx!.strokeStyle = rgba(rgb, alphaAt(d0)) - line(xOf(l, stem.staff, inner), y, tipX, y) - continue - } - for (const n of stem.notes) { - const d = d0 + (n.t - stem.t0) - const y = yOf(l, d) + side - ctx!.strokeStyle = rgba(rgb, alphaAt(d)) - line(xOf(l, stem.staff, n.pitch), y, tipX, y) - } - if (stem.beams === 0) continue - const nearest = d1 < 0 ? d1 : d0 > 0 ? d0 : 0 - ctx!.strokeStyle = rgba(rgb, alphaAt(nearest)) - ctx!.lineWidth = sp * 0.36 - const yA = yOf(l, d0) + side + stemW / 2 - const yB = yOf(l, d1) + side - stemW / 2 - const offset = sp * 0.18 * out - line(tipX - offset, yA, tipX - offset, yB) - if (stem.beams === 2) { - const x2 = tipX - offset - out * sp * 0.75 - line(x2, yA, x2, yB) + const len = stem.t1 - stem.t0 + for (const d0 of spots(l, stem.t0, len)) { + const d1 = d0 + len + const out = stem.staff === 0 ? -1 : 1 + const tipX = xOf(l, stem.staff, stem.tip) + lw = stemW + if (stem.chord) { + // One stem through the chord, from the innermost head out to the tip. + const inner = + out < 0 + ? Math.max(...stem.notes.map((n) => n.pitch)) + : Math.min(...stem.notes.map((n) => n.pitch)) + const y = yOf(l, d0) + side + ctx!.strokeStyle = rgba(rgb, alphaAt(d0)) + line(xOf(l, stem.staff, inner), y, tipX, y) + continue + } + for (const n of stem.notes) { + const d = d0 + (n.t - stem.t0) + const y = yOf(l, d) + side + ctx!.strokeStyle = rgba(rgb, alphaAt(d)) + line(xOf(l, stem.staff, n.pitch), y, tipX, y) + } + if (stem.beams === 0) continue + const nearest = d1 < 0 ? d1 : d0 > 0 ? d0 : 0 + ctx!.strokeStyle = rgba(rgb, alphaAt(nearest)) + lw = sp * 0.36 + const yA = yOf(l, d0) + side + stemW / 2 + const yB = yOf(l, d1) + side - stemW / 2 + const offset = sp * 0.18 * out + line(tipX - offset, yA, tipX - offset, yB) + if (stem.beams === 2) { + const x2 = tipX - offset - out * sp * 0.75 + line(x2, yA, x2, yB) + } } } // Ledger lines, parallel to the staff lines. - ctx!.lineWidth = 1 + lw = 1 for (const n of s.notes) { if (n.pitch > -2 && n.pitch < 10) continue - const d = wrapDelta(n.t, beat) - if (!inView(d)) continue - const y = yOf(l, d) - ctx!.strokeStyle = rgba(rgb, alphaAt(d) * 0.7) - const from = n.pitch < 0 ? -2 : 10 - const step = n.pitch < 0 ? -2 : 2 - for (let p = from; step < 0 ? p >= n.pitch : p <= n.pitch; p += step) { - const x = xOf(l, n.staff, p) - line(x, y - ry * 1.7, x, y + ry * 1.7) + for (const d of spots(l, n.t)) { + const y = yOf(l, d) + ctx!.strokeStyle = rgba(rgb, alphaAt(d) * 0.7) + const from = n.pitch < 0 ? -2 : 10 + const step = n.pitch < 0 ? -2 : 2 + for (let p = from; step < 0 ? p >= n.pitch : p <= n.pitch; p += step) { + const x = xOf(l, n.staff, p) + line(x, y - ry * 1.7, x, y + ry * 1.7) + } } } @@ -768,63 +1043,85 @@ export function createPhosphorScore( if (bloom > 0) { ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" for (const n of s.notes) { - const d = wrapDelta(n.t, beat) - const lit = litAt(d) - if (lit < 0.03 || !inView(d)) continue - const r = sp * (1.1 + lit * 1.5) * bloom - ctx!.globalAlpha = - lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity - ctx!.drawImage( - glowSprite, - xOf(l, n.staff, n.pitch) - r, - yOf(l, d) - r, - r * 2, - r * 2 - ) + for (const d of spots(l, n.t)) { + const lit = litAt(d) + if (lit < 0.03) continue + const r = sp * (1.1 + lit * 1.5) * bloom + glow( + xOf(l, n.staff, n.pitch), + yOf(l, d), + r, + r, + lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity + ) + } } - ctx!.globalAlpha = 1 ctx!.globalCompositeOperation = "source-over" } // Noteheads, with accidentals just before them in reading order (below). ctx!.font = `${Math.round(sp * 1.5)}px Georgia, "Times New Roman", serif` for (const n of s.notes) { - const d = wrapDelta(n.t, beat) - if (!inView(d)) continue - const x = xOf(l, n.staff, n.pitch) - const y = yOf(l, d) - const color = rgba(rgb, alphaAt(d)) - ctx!.beginPath() - ctx!.ellipse(x, y, rx, ry, 0.35, 0, Math.PI * 2) - if (n.hollow) { - ctx!.strokeStyle = color - ctx!.lineWidth = Math.max(1.2, sp * 0.16) - ctx!.stroke() - } else { - ctx!.fillStyle = color - ctx!.fill() - } - if (n.acc !== 0) { - ctx!.fillStyle = color - ctx!.fillText(n.acc > 0 ? "♯" : "♭", x, y + ry + sp * 0.95) + for (const d of spots(l, n.t)) { + const fog = enter(xOf(l, n.staff, n.pitch), yOf(l, d)) + if (fog <= 0) { + leave() + continue + } + ctx!.globalAlpha = fog + const color = rgba(rgb, alphaAt(d)) + ctx!.beginPath() + ctx!.ellipse(0, 0, rx, ry, 0.35, 0, Math.PI * 2) + if (n.hollow) { + ctx!.strokeStyle = color + ctx!.lineWidth = Math.max(1.2, sp * 0.16) + ctx!.stroke() + } else { + ctx!.fillStyle = color + ctx!.fill() + } + if (n.acc !== 0) { + ctx!.fillStyle = color + ctx!.fillText(n.acc > 0 ? "♯" : "♭", 0, ry + sp * 0.95) + } + leave() } } } function drawPlayhead(l: Layout, bloom: number) { - const { w } = size const y = Math.round(l.playY) + 0.5 if (bloom > 0) { ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" - ctx!.globalAlpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom) + const alpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom) const band = l.sp * 1.4 * Math.max(0.6, bloom) - ctx!.drawImage(glowSprite, 0, y - band, w, band * 2) - ctx!.globalAlpha = 1 + // In slices, so the band follows the perspective across the plane. + const slices = view.depthless ? 1 : 24 + const span = (view.x1 - view.x0) / slices + const src = glowSprite.width / slices + for (let i = 0; i < slices; i++) { + const fog = enter(view.x0 + (i + 0.5) * span, y) + if (fog > 0) { + ctx!.globalAlpha = alpha * fog + ctx!.drawImage( + glowSprite, + i * src, + 0, + src, + glowSprite.height, + -span / 2, + -band, + span, + band * 2 + ) + } + leave() + } ctx!.globalCompositeOperation = "source-over" } ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55) - ctx!.lineWidth = 1 - line(0, y, w, y) + lw = 1 + line(view.x0, y, view.x1, y, 12) } function drawFlares(l: Layout, bloom: number) { @@ -835,14 +1132,11 @@ export function createPhosphorScore( const fade = (1 - k) * (1 - k) const x = xOf(l, f.staff, f.pitch) const r = l.sp * (1.6 + k * 1.4) * bloom - ctx!.globalAlpha = fade * (dark ? 0.8 : 0.4) - ctx!.drawImage(glowSprite, x - r, l.playY - r, r * 2, r * 2) + glow(x, l.playY, r, r, fade * (dark ? 0.8 : 0.4)) // A streak along the playhead. const streak = l.sp * (2.5 + k * 3) * bloom - ctx!.globalAlpha = fade * (dark ? 0.9 : 0.5) - ctx!.drawImage(glowSprite, x - streak, l.playY - 1.5, streak * 2, 3) + glow(x, l.playY, streak, 1.5, fade * (dark ? 0.9 : 0.5)) } - ctx!.globalAlpha = 1 ctx!.globalCompositeOperation = "source-over" } diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index af42847..7b2cdaf 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -26,6 +26,9 @@ density = DEFAULT_DENSITY, sway = true, seed = DEFAULT_SEED, + rotateX = 0, + rotateY = 0, + rotateZ = 0, theme = "auto", }: Props = $props() @@ -51,6 +54,9 @@ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, }) return () => { @@ -67,6 +73,9 @@ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, }) }) diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index d312478..cdd5f88 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -61,6 +61,9 @@ export function PhosphorScore({ density = DEFAULT_DENSITY, sway = true, seed = DEFAULT_SEED, + rotateX = 0, + rotateY = 0, + rotateZ = 0, theme = "auto", }: PhosphorScoreProps) { const canvasRef = useRef(null) @@ -76,6 +79,9 @@ export function PhosphorScore({ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, }) return () => { @@ -94,9 +100,23 @@ export function PhosphorScore({ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, }) - }, [color, glow, speed, density, sway, seed, theme]) + }, [ + color, + glow, + speed, + density, + sway, + seed, + rotateX, + rotateY, + rotateZ, + theme, + ]) return (
Date: Sun, 27 Sep 2026 22:14:26 +0530 Subject: [PATCH 13/49] Animate the page outline with an SVG rail. The rail draws in and steps inward for nested headings, a lit segment glides to the hovered item, a triad pulse runs to the clicked heading, and a small scope trace heads the list. Still no scroll tracking; all motion stops under prefers-reduced-motion. --- app/globals.css | 30 ++++ components/docs-toc-outline.tsx | 234 ++++++++++++++++++++++++++++++++ components/docs-toc.tsx | 31 +---- 3 files changed, 268 insertions(+), 27 deletions(-) create mode 100644 components/docs-toc-outline.tsx diff --git a/app/globals.css b/app/globals.css index e00cbac..51f695a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -56,6 +56,36 @@ --radius-3xl: calc(var(--radius) * 1.25); --radius-4xl: calc(var(--radius) * 1.3); --animate-sidebar-open-blur: sidebar-open-blur 280ms cubic-bezier(0.22, 1, 0.36, 1); + --animate-toc-rail: toc-rail 720ms cubic-bezier(0.22, 1, 0.36, 1) both; + --animate-toc-item: toc-item 420ms cubic-bezier(0.22, 1, 0.36, 1) both; + --animate-toc-scope: toc-scope 1.1s linear infinite; +} + +/* On this page — the SVG rail draws in, rows follow it, the scope trace runs. */ +@keyframes toc-rail { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@keyframes toc-item { + from { + opacity: 0; + translate: -3px 0; + } + to { + opacity: 1; + translate: 0 0; + } +} + +@keyframes toc-scope { + to { + transform: translateX(-12px); + } } @keyframes sidebar-open-blur { diff --git a/components/docs-toc-outline.tsx b/components/docs-toc-outline.tsx new file mode 100644 index 0000000..4d07cf8 --- /dev/null +++ b/components/docs-toc-outline.tsx @@ -0,0 +1,234 @@ +"use client" + +import { useLayoutEffect, useRef, useState } from "react" +import type { TOCItemType } from "fumadocs-core/toc" + +import { cn } from "@/lib/utils" + +/** Rail x per heading depth; the rail steps inward for nested headings. */ +function railX(depth: number) { + if (depth <= 2) return 0.5 + if (depth === 3) return 10.5 + return 18.5 +} + +/** Room each step's diagonal takes out of the rows it joins. */ +const STEP = 6 +/** Length of each triad dash in the click pulse. */ +const PULSE = 8 + +type Rail = { + d: string + total: number + /** Where each item's stretch of rail starts and ends, along the path. */ + spans: [number, number][] + height: number +} + +function buildRail(list: HTMLUListElement, items: TOCItemType[]): Rail | null { + const rows = Array.from(list.children) as HTMLElement[] + if (rows.length === 0 || rows.length !== items.length) return null + const xs = items.map((item) => railX(item.depth)) + const points: [number, number][] = [] + const spans: [number, number][] = [] + let total = 0 + const to = (x: number, y: number) => { + const last = points[points.length - 1] + if (last) total += Math.hypot(x - last[0], y - last[1]) + points.push([x, y]) + return total + } + rows.forEach((row, i) => { + const x = xs[i]! + const top = row.offsetTop + const bottom = top + row.offsetHeight + const stepIn = i > 0 && xs[i - 1] !== x + const stepOut = i < rows.length - 1 && xs[i + 1] !== x + const start = to(x, top + (stepIn ? STEP : 0)) + const end = to(x, bottom - (stepOut ? STEP : 0)) + spans.push([start, end]) + }) + return { + d: points.map(([x, y], i) => `${i ? "L" : "M"}${x} ${y}`).join(""), + total, + spans, + height: list.offsetHeight, + } +} + +/** + * The page outline. An SVG rail draws itself in and steps inward for nested + * headings; a lit segment glides along it to the hovered item, and clicking + * sends a triad pulse down the rail to the heading you jumped to. No scroll + * tracking. + */ +export function DocsTocOutline({ items }: { items: TOCItemType[] }) { + const listRef = useRef(null) + const pulseRefs = useRef<(SVGPathElement | null)[]>([]) + const [rail, setRail] = useState(null) + const [hover, setHover] = useState(null) + const [lit, setLit] = useState(0) + + useLayoutEffect(() => { + const list = listRef.current + if (!list) return + const measure = () => setRail(buildRail(list, items)) + measure() + const ro = new ResizeObserver(measure) + ro.observe(list) + return () => ro.disconnect() + }, [items]) + + function enter(index: number) { + setHover(index) + setLit(index) + } + + function pulse(index: number) { + const span = rail?.spans[index] + if (!rail || !span) return + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + const target = (span[0] + span[1]) / 2 + PULSE / 2 + const duration = Math.min(900, 380 + target * 1.2) + pulseRefs.current.forEach((path, i) => { + path?.animate( + [ + { strokeDashoffset: PULSE, opacity: 1 }, + { strokeDashoffset: -(target - PULSE), opacity: 1, offset: 0.75 }, + { strokeDashoffset: -(target - PULSE), opacity: 0 }, + ], + { + duration, + delay: (2 - i) * 45, + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + fill: "backwards", + } + ) + }) + } + + const span = rail?.spans[lit] + const litLength = span ? Math.max(0, span[1] - span[0]) : 0 + + return ( + + ) +} + +/** + * A little oscilloscope trace. It draws in once, then runs while the + * outline is hovered. + */ +function ScopeTrace() { + // Two periods of a sine, so sliding by one period loops seamlessly. + const wave = Array.from({ length: 33 }, (_, i) => { + const x = i * 0.75 + const y = 5 - Math.sin((i / 16) * Math.PI * 2) * 3.2 + return `${i ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}` + }).join("") + return ( + + + + + + ) +} diff --git a/components/docs-toc.tsx b/components/docs-toc.tsx index 47e8dea..009895a 100644 --- a/components/docs-toc.tsx +++ b/components/docs-toc.tsx @@ -2,39 +2,16 @@ import type { TOCItemType } from "fumadocs-core/toc" import Link from "next/link" import { RiArrowRightUpLine, RiHeartFill } from "@remixicon/react" -import { cn } from "@/lib/utils" +import { DocsTocOutline } from "@/components/docs-toc-outline" /** - * Right rail on the bench, outside the page sheet: a plain page outline (no - * scroll tracking), with the sponsor slot underneath. + * Right rail on the bench, outside the page sheet: the animated page outline + * (no scroll tracking), with the sponsor slot underneath. */ export function DocsToc({ items }: { items: TOCItemType[] }) { return (
\n {/each}\n \n\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n {@render children?.()}\n \n {@render aurora()}\n \n{/if}\n", + "content": "\n\n\n\n{#snippet aurora()}\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n \n
\n {@render children?.()}\n
\n \n {@render aurora()}\n \n{/if}\n", "type": "registry:file", "target": "src/lib/components/ui/stretchy-footer.svelte" } diff --git a/public/r/stretchy-footer.json b/public/r/stretchy-footer.json index bc00a2a..41513f9 100644 --- a/public/r/stretchy-footer.json +++ b/public/r/stretchy-footer.json @@ -3,13 +3,10 @@ "name": "stretchy-footer", "title": "Stretchy Footer", "description": "Dia-style rubber overscroll — rainbow aurora stretches up past the bottom, then snaps back.", - "dependencies": [ - "motion" - ], "files": [ { "path": "registry/stretchy-footer/stretchy-footer.tsx", - "content": "\"use client\"\n\nimport {\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n motion,\n useMotionValue,\n useMotionValueEvent,\n useReducedMotion,\n useSpring,\n useTransform,\n} from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_COLORS = [\n \"#FF3B30\",\n \"#FF9500\",\n \"#FFCC00\",\n \"#34C759\",\n \"#00C7BE\",\n \"#32ADE6\",\n \"#007AFF\",\n \"#5856D6\",\n \"#AF52DE\",\n \"#FF2D55\",\n]\n\nexport const DEFAULT_COLUMNS = 48\n/** Gaussian blur on the aurora bars, in px. */\nexport const DEFAULT_BLUR = 14\n/** White floor bloom opacity (0–1). */\nexport const DEFAULT_GLOW = 0.22\nexport const WHEEL_IDLE_MS = 90\nexport const PULL_GAIN = 0.7\nexport const POP_BOOST = 1.45\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n if (root) {\n const max = Math.max(0, root.scrollHeight - root.clientHeight)\n root.scrollTo({ top: max, behavior: \"smooth\" })\n const started = performance.now()\n while (performance.now() - started < 2000) {\n if (root.scrollTop >= max - 2) break\n await new Promise((r) => window.setTimeout(r, 32))\n }\n root.scrollTo({ top: max })\n return\n }\n\n const max = Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n window.scrollTo({ top: max, behavior: \"smooth\" })\n const started = performance.now()\n while (performance.now() - started < 2000) {\n const top = window.scrollY || document.documentElement.scrollTop\n if (top >= max - 2) break\n await new Promise((r) => window.setTimeout(r, 32))\n }\n window.scrollTo({ top: max })\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n\n await scrollToEnd(detail.scrollRoot)\n\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, {\n detail,\n })\n )\n\n const hold = detail.holdMs ?? 700\n // Travel out + hold + spring home\n await new Promise((r) => window.setTimeout(r, hold + 900))\n}\n\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return Math.exp(-t * t * 2.2)\n}\n\n/** Stable rgba() for SSR — avoids hex/rgb serialization mismatches. */\nexport function hexToRgba(hex: string, alpha: number): string {\n const raw = hex.replace(\"#\", \"\")\n const full =\n raw.length === 3\n ? raw\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : raw.slice(0, 6)\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 return `rgba(${r}, ${g}, ${b}, ${alpha})`\n}\n\nexport function applyResistance(current: number, delta: number, max: number): number {\n if (max <= 0) return 0\n const t = Math.min(1, Math.max(0, current / max))\n const falloff = (1 - t) * (1 - t * 0.35)\n const pop = current < 40 ? POP_BOOST : 1\n return Math.min(max, Math.max(0, current + delta * PULL_GAIN * falloff * pop))\n}\n\nexport function elementAtBottom(el: HTMLElement, slack = 1): boolean {\n return el.scrollTop + el.clientHeight >= el.scrollHeight - slack\n}\n\nexport function windowAtBottom(slack = 1): boolean {\n const doc = document.documentElement\n return window.scrollY + window.innerHeight >= doc.scrollHeight - slack\n}\n\nexport type StretchyFooterProps = {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll listeners to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages (e.g. the component docs) instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /** Spectrum stops across the aurora columns. */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many vertical aurora bars. Default `48`. */\n columns?: number\n /** Gaussian blur on the aurora bars, in px. Default `14`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0.22`. */\n glow?: number\n /** Accessible label for the decorative field. */\n label?: string\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * animates footers whose `demoId` matches.\n */\n demoId?: string\n}\n\ntype OverscrollTarget =\n | { kind: \"element\"; el: HTMLElement }\n | { kind: \"window\" }\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * field scaleY-stretches from the floor while the page lifts with it.\n * GPU transforms only (no height animation) for a clean, snappy rubber band.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = 280,\n colors = DEFAULT_COLORS,\n stiffness = 380,\n damping = 32,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const reduceMotion = useReducedMotion() ?? false\n const internalRef = useRef(null)\n const pull = useMotionValue(0)\n const stretch = useSpring(pull, {\n stiffness,\n damping,\n mass: 0.35,\n restDelta: 0.2,\n restSpeed: 0.2,\n })\n const touchStartY = useRef(0)\n const touchStretch0 = useRef(0)\n const wheelTimer = useRef | null>(null)\n const demoTimer = useRef | null>(null)\n const demoPlayingRef = useRef(false)\n const rolledEl = useRef(null)\n const [mounted, setMounted] = useState(false)\n /** User-triggered demo — plays even when prefers-reduced-motion is on. */\n const [demoPlaying, setDemoPlaying] = useState(false)\n demoPlayingRef.current = demoPlaying\n // Gate on `mounted` so prefers-reduced-motion can't mismatch SSR HTML.\n const reduce = mounted && reduceMotion && !demoPlaying\n\n useEffect(() => {\n setMounted(true)\n }, [])\n\n // 0 → 1 progress drives scaleY (compositor-friendly).\n const progress = useTransform(stretch, (v) =>\n maxStretch <= 0 ? 0 : Math.min(1, Math.max(0, v / maxStretch))\n )\n const pageY = useTransform(stretch, (v) => -v)\n const fieldOpacity = useTransform(progress, [0, 0.08, 0.2], [0, 1, 1])\n\n const blurPx = Math.max(0, blur)\n const glowAlpha = Math.min(1, Math.max(0, glow))\n\n const bars = useMemo(() => {\n const count = Math.max(8, Math.min(96, Math.floor(columns)))\n return Array.from({ length: count }, (_, i) => {\n const color = colors[i % colors.length]!\n const heightPct = Math.max(28, columnScale(i, count) * 100).toFixed(2)\n return {\n key: i,\n height: `${heightPct}%`,\n backgroundImage: `linear-gradient(to top, ${hexToRgba(color, 1)} 0%, ${hexToRgba(color, 0.6)} 45%, rgba(0, 0, 0, 0) 100%)`,\n }\n })\n }, [colors, columns])\n\n // Lift the page in lockstep with the stretch — translate only, no 3D.\n useMotionValueEvent(stretch, \"change\", (v) => {\n if (reduceMotion && !demoPlayingRef.current) return\n if (!windowScroll && !scrollRef) return\n\n if (!rolledEl.current?.isConnected) {\n rolledEl.current = document.querySelector(\n contentSelector\n ) as HTMLElement | null\n }\n const el = rolledEl.current\n if (!el) return\n\n const y = -Math.max(0, v)\n el.style.transformOrigin = \"50% 100%\"\n el.style.willChange = \"transform\"\n el.style.transform = `translate3d(0, ${y}px, 0)`\n\n if (v < 0.2) {\n el.style.transform = \"translate3d(0, 0, 0)\"\n el.style.willChange = \"\"\n }\n })\n\n useEffect(() => {\n return () => {\n const el = rolledEl.current\n if (!el) return\n el.style.transform = \"\"\n el.style.transformOrigin = \"\"\n el.style.willChange = \"\"\n }\n }, [])\n\n // Always listen for docs \"Show effect\" — drives pull directly (no fake wheels).\n useEffect(() => {\n const clearDemoTimer = () => {\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n }\n }\n\n const onPlay = (event: Event) => {\n const detail =\n (event as CustomEvent).detail ?? {}\n if (detail.target != null && detail.target !== demoId) return\n // Scoped footers ignore broadcast plays without a target.\n if (detail.target == null && demoId != null) return\n\n const amount = detail.amount ?? 0.82\n const holdMs = detail.holdMs ?? 700\n\n if (wheelTimer.current) {\n clearTimeout(wheelTimer.current)\n wheelTimer.current = null\n }\n clearDemoTimer()\n demoPlayingRef.current = true\n setDemoPlaying(true)\n pull.set(Math.min(maxStretch, Math.max(0, maxStretch * amount)))\n demoTimer.current = setTimeout(() => {\n demoTimer.current = null\n pull.set(0)\n // Wait for the spring to settle before re-honoring reduced motion.\n window.setTimeout(() => {\n demoPlayingRef.current = false\n setDemoPlaying(false)\n }, 500)\n }, holdMs)\n }\n\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlay)\n return () => {\n clearDemoTimer()\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlay)\n }\n }, [demoId, maxStretch, pull])\n\n useEffect(() => {\n if (reduceMotion) return\n\n let target: OverscrollTarget | null = null\n if (windowScroll) {\n target = { kind: \"window\" }\n } else {\n const el = scrollRef?.current ?? internalRef.current\n if (el) target = { kind: \"element\", el }\n }\n if (!target) return\n\n // Kill browser rubber-band so it doesn't fight ours.\n const prevOverscroll =\n target.kind === \"window\"\n ? document.documentElement.style.overscrollBehaviorY\n : target.el.style.overscrollBehaviorY\n if (target.kind === \"window\") {\n document.documentElement.style.overscrollBehaviorY = \"none\"\n } else {\n target.el.style.overscrollBehaviorY = \"none\"\n }\n\n const isAtBottom = () =>\n target.kind === \"window\" ? windowAtBottom() : elementAtBottom(target.el)\n\n const clearWheelTimer = () => {\n if (wheelTimer.current) {\n clearTimeout(wheelTimer.current)\n wheelTimer.current = null\n }\n }\n\n const pullTo = (next: number) => {\n pull.set(next)\n }\n\n const snapBack = () => {\n clearWheelTimer()\n pull.set(0)\n }\n\n const onWheel = (event: WheelEvent) => {\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n setDemoPlaying(false)\n }\n const current = pull.get()\n const scrollingDown = event.deltaY > 0\n const scrollingUp = event.deltaY < 0\n\n if (current > 0.5 || stretch.get() > 0.5) {\n event.preventDefault()\n if (scrollingUp) {\n const next = Math.max(0, current + event.deltaY * PULL_GAIN)\n if (next <= 0.5) {\n snapBack()\n return\n }\n pullTo(next)\n } else if (scrollingDown) {\n pullTo(applyResistance(current, event.deltaY, maxStretch))\n }\n clearWheelTimer()\n wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS)\n return\n }\n\n if (scrollingDown && isAtBottom()) {\n event.preventDefault()\n pullTo(applyResistance(0, event.deltaY, maxStretch))\n clearWheelTimer()\n wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const t = event.touches[0]\n if (!t) return\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n setDemoPlaying(false)\n }\n touchStartY.current = t.clientY\n touchStretch0.current = pull.get()\n }\n\n const onTouchMove = (event: TouchEvent) => {\n const t = event.touches[0]\n if (!t) return\n const dy = touchStartY.current - t.clientY\n const current = pull.get()\n\n if (current > 0.5 || stretch.get() > 0.5 || (dy > 0 && isAtBottom())) {\n if (event.cancelable) event.preventDefault()\n const boost = current < 40 ? POP_BOOST : 1\n const fromRest = touchStretch0.current + dy * PULL_GAIN * boost\n pullTo(Math.min(maxStretch, Math.max(0, fromRest)))\n }\n }\n\n const onTouchEnd = () => {\n if (pull.get() > 0.5 || stretch.get() > 0.5) snapBack()\n }\n\n const optsWheel = { passive: false } as const\n const optsTouchStart = { passive: true } as const\n const optsTouchMove = { passive: false } as const\n\n if (target.kind === \"window\") {\n window.addEventListener(\"wheel\", onWheel, optsWheel)\n window.addEventListener(\"touchstart\", onTouchStart, optsTouchStart)\n window.addEventListener(\"touchmove\", onTouchMove, optsTouchMove)\n window.addEventListener(\"touchend\", onTouchEnd)\n window.addEventListener(\"touchcancel\", onTouchEnd)\n } else {\n const el = target.el\n el.addEventListener(\"wheel\", onWheel, optsWheel)\n el.addEventListener(\"touchstart\", onTouchStart, optsTouchStart)\n el.addEventListener(\"touchmove\", onTouchMove, optsTouchMove)\n el.addEventListener(\"touchend\", onTouchEnd)\n el.addEventListener(\"touchcancel\", onTouchEnd)\n }\n\n return () => {\n clearWheelTimer()\n if (target.kind === \"window\") {\n document.documentElement.style.overscrollBehaviorY = prevOverscroll\n window.removeEventListener(\"wheel\", onWheel)\n window.removeEventListener(\"touchstart\", onTouchStart)\n window.removeEventListener(\"touchmove\", onTouchMove)\n window.removeEventListener(\"touchend\", onTouchEnd)\n window.removeEventListener(\"touchcancel\", onTouchEnd)\n } else {\n target.el.style.overscrollBehaviorY = prevOverscroll\n const el = target.el\n el.removeEventListener(\"wheel\", onWheel)\n el.removeEventListener(\"touchstart\", onTouchStart)\n el.removeEventListener(\"touchmove\", onTouchMove)\n el.removeEventListener(\"touchend\", onTouchEnd)\n el.removeEventListener(\"touchcancel\", onTouchEnd)\n }\n }\n }, [reduceMotion, scrollRef, windowScroll, maxStretch, pull, stretch])\n\n const aurora = (\n \n \n {/* Single blur layer — cheaper than per-bar filters */}\n \n {bars.map((bar) => (\n \n ))}\n \n\n {/* Soft floor bloom */}\n \n \n \n )\n\n if (windowScroll) {\n if (!mounted) return null\n\n // Portal to body so `fixed` stays on the viewport — a transformed\n // ancestor (the rolling page) would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n document.body\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n return (\n \n \n {children}\n \n {aurora}\n \n )\n}\n", + "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_COLORS = [\n \"#FF3B30\",\n \"#FF9500\",\n \"#FFCC00\",\n \"#34C759\",\n \"#00C7BE\",\n \"#32ADE6\",\n \"#007AFF\",\n \"#5856D6\",\n \"#AF52DE\",\n \"#FF2D55\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 48\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 14\n/** White floor bloom opacity (0–1). */\nexport const DEFAULT_GLOW = 0.22\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Shortest column as a share of the stretch. */\nconst MIN_COLUMN = 0.28\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /** Spectrum stops across the aurora columns, any CSS colors. */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns. Default `48`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `14`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0.22`. */\n glow?: number\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/** Bell curve across the columns: tall in the middle, short at the edges. */\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return Math.exp(-t * t * 2.2)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\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/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction — a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\n/** A vertical strip of `color`: solid at the floor, fading out at the top. */\nfunction makeStrip(color: string) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = 64\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n g.fillStyle = color\n g.fillRect(0, 0, 1, 64)\n const fade = g.createLinearGradient(0, 0, 0, 64)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(0.55, \"rgba(0,0,0,0.6)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, 64)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while an aurora of blurred columns stretches up\n * from the floor; each column has its own spring, so the edge wobbles as it\n * snaps back. Idle unless stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 8, 96))\n\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strips: HTMLCanvasElement[] = []\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = Math.max(MIN_COLUMN, columnScale(i, count))\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrips = () => {\n const colors = options.colors?.length ? options.colors : DEFAULT_COLORS\n strips = colors.map(makeStrip)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway — half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(stretch)\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n const strip = strips[i % strips.length]!\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n ctx!.drawImage(strip, 0, 63, 1, 1, x0, floor, x1 - x0, bottom - floor)\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const max = maxOf()\n const target = forced ?? rubberBand(distance, max)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrips()\n rebuildColumns()\n layout()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrips()\n if (countOf() !== columns.length) rebuildColumns()\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n { maxStretch, colors, stiffness, damping, columns, blur, glow, demoId }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n demoId,\n })\n }, [maxStretch, colors, stiffness, damping, columns, blur, glow, demoId])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport — the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/stretchy-footer.tsx" } diff --git a/registry/stretchy-footer/registry.json b/registry/stretchy-footer/registry.json index 966ca53..a088a7f 100644 --- a/registry/stretchy-footer/registry.json +++ b/registry/stretchy-footer/registry.json @@ -6,7 +6,6 @@ "type": "registry:ui", "title": "Stretchy Footer", "description": "Dia-style rubber overscroll — rainbow aurora stretches up past the bottom, then snaps back.", - "dependencies": ["motion"], "files": [ { "path": "stretchy-footer.tsx", diff --git a/registry/stretchy-footer/stretchy-footer-demo.tsx b/registry/stretchy-footer/stretchy-footer-demo.tsx index 0ed426a..057a728 100644 --- a/registry/stretchy-footer/stretchy-footer-demo.tsx +++ b/registry/stretchy-footer/stretchy-footer-demo.tsx @@ -11,16 +11,16 @@ import { import { ComponentPreview } from "@/components/component-preview" import { Button } from "@/components/ui/button" import { usePreviewProps } from "@/hooks/use-preview-props" -import { - playStretchyFooterDemo, - StretchyFooter, -} from "@/registry/stretchy-footer/stretchy-footer" import { DEFAULT_BLUR, DEFAULT_COLORS, DEFAULT_COLUMNS, DEFAULT_GLOW, -} from "@/registry/stretchy-footer/stretchy-footer-vanilla" + DEFAULT_MAX_STRETCH, + DEFAULT_STIFFNESS, + playStretchyFooterDemo, + StretchyFooter, +} from "@/registry/stretchy-footer/stretchy-footer" const PREVIEW_DEMO_ID = "stretchy-footer-preview" @@ -49,7 +49,7 @@ export function StretchyFooterDemo() { colors: SPECTRUM, maxStretch: 220, columns: DEFAULT_COLUMNS, - stiffness: 380, + stiffness: DEFAULT_STIFFNESS, blur: DEFAULT_BLUR, glow: DEFAULT_GLOW, }), @@ -82,7 +82,6 @@ export function StretchyFooterDemo() { >
void - get: () => number - onChange: (fn: (value: number) => void) => () => void - destroy: () => void -} - -/** - * Damped spring for the Svelte port (React keeps motion springs). - * Semi-implicit Euler with a 60 Hz substep. - */ -export function createSpring({ - stiffness, - damping, - mass, - restDelta = 0.2, - restSpeed = 0.2, - initial = 0, -}: SpringOptions): Spring { - let current = initial - let target = initial - let velocity = 0 - let raf = 0 - let lastTime = 0 - const listeners = new Set<(value: number) => void>() - - function emit() { - for (const fn of listeners) fn(current) - } - - function integrate(dt: number) { - const springForce = -stiffness * (current - target) - const dampingForce = -damping * velocity - const accel = (springForce + dampingForce) / mass - velocity += accel * dt - current += velocity * dt - } - - function tick(now: number) { - if (!lastTime) lastTime = now - let remaining = Math.min((now - lastTime) / 1000, 0.064) - lastTime = now - - const step = 1 / 60 - while (remaining > 0) { - integrate(Math.min(step, remaining)) - remaining -= step - } - - const settled = - Math.abs(current - target) < restDelta && Math.abs(velocity) < restSpeed - - if (settled) { - current = target - velocity = 0 - raf = 0 - lastTime = 0 - emit() - return - } - - emit() - raf = requestAnimationFrame(tick) - } - - function start() { - if (!raf) { - lastTime = 0 - raf = requestAnimationFrame(tick) - } - } - - return { - set(value) { - target = value - if ( - Math.abs(current - target) < restDelta && - Math.abs(velocity) < restSpeed - ) { - current = target - velocity = 0 - emit() - return - } - start() - }, - get() { - return current - }, - onChange(fn) { - listeners.add(fn) - return () => { - listeners.delete(fn) - } - }, - destroy() { - if (raf) cancelAnimationFrame(raf) - raf = 0 - listeners.clear() - }, - } -} diff --git a/registry/stretchy-footer/stretchy-footer-vanilla.ts b/registry/stretchy-footer/stretchy-footer-vanilla.ts index 82bf9ea..688c56f 100644 --- a/registry/stretchy-footer/stretchy-footer-vanilla.ts +++ b/registry/stretchy-footer/stretchy-footer-vanilla.ts @@ -11,18 +11,66 @@ export const DEFAULT_COLORS = [ "#FF2D55", ] +export const DEFAULT_MAX_STRETCH = 280 export const DEFAULT_COLUMNS = 48 -/** Gaussian blur on the aurora bars, in px. */ +/** Gaussian blur on the aurora, in px. */ export const DEFAULT_BLUR = 14 /** White floor bloom opacity (0–1). */ export const DEFAULT_GLOW = 0.22 -export const WHEEL_IDLE_MS = 90 -export const PULL_GAIN = 0.7 -export const POP_BOOST = 1.45 +export const DEFAULT_STIFFNESS = 380 +export const DEFAULT_DAMPING = 32 /** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */ export const STRETCHY_FOOTER_PLAY = "stretchy-footer:play" +/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */ +const RUBBER = 0.9 +/** Wheel silence that counts as letting go, in ms. */ +const RELEASE_MS = 110 +/** After letting go, wheel events closer together than this are momentum. */ +const MOMENTUM_GAP_MS = 180 +/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */ +const COAST_EVENTS = 6 +const MASS = 0.35 +const REST = 0.15 +/** Shortest column as a share of the stretch. */ +const MIN_COLUMN = 0.28 +/** Field opacity ramps in over this share of `maxStretch`. */ +const FADE_IN = 0.08 +/** Physics substep, in seconds. */ +const STEP = 1 / 120 + +export type StretchyFooterOptions = { + /** Peak stretch in px. Default `280`. */ + maxStretch?: number + /** Spectrum stops across the aurora columns, any CSS colors. */ + colors?: string[] + /** Spring stiffness. Default `380`. */ + stiffness?: number + /** Spring damping. Default `32`. */ + damping?: number + /** How many aurora columns. Default `48`. */ + columns?: number + /** Gaussian blur on the aurora, in px. Default `14`. */ + blur?: number + /** White floor bloom opacity (0–1). Default `0.22`. */ + glow?: number + /** + * Optional id for docs demos. `playStretchyFooterDemo({ target })` only + * plays footers whose `demoId` matches. + */ + demoId?: string +} + +export type StretchyFooterMount = { + /** Canvas the aurora paints into. Its parent sets the width. */ + canvas: HTMLCanvasElement + /** Where overscroll is read: a scroll container, or `window`. */ + scroller: HTMLElement | Window + /** Lifts with the stretch: an element, or a selector looked up when needed. */ + content?: HTMLElement | string | null +} + export type StretchyFooterPlayDetail = { /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */ amount?: number @@ -34,31 +82,25 @@ export type StretchyFooterPlayDetail = { scrollRoot?: HTMLElement | null } -export async function scrollToEnd(root?: HTMLElement | null) { - if (root) { - const max = Math.max(0, root.scrollHeight - root.clientHeight) - root.scrollTo({ top: max, behavior: "smooth" }) - const started = performance.now() - while (performance.now() - started < 2000) { - if (root.scrollTop >= max - 2) break - await new Promise((r) => window.setTimeout(r, 32)) - } - root.scrollTo({ top: max }) - return - } +export type StretchyFooterInstance = { + setOptions: (options: Partial) => void + /** Stretch and snap back, even under `prefers-reduced-motion`. */ + play: (detail?: Pick) => void + destroy: () => void +} - const max = Math.max( - 0, - document.documentElement.scrollHeight - window.innerHeight - ) - window.scrollTo({ top: max, behavior: "smooth" }) +export async function scrollToEnd(root?: HTMLElement | null) { + const el = root ?? document.documentElement + const max = () => Math.max(0, el.scrollHeight - el.clientHeight) + const top = () => (root ? root.scrollTop : window.scrollY) + const scroll = (behavior: ScrollBehavior) => + (root ?? window).scrollTo({ top: max(), behavior }) + scroll("smooth") const started = performance.now() - while (performance.now() - started < 2000) { - const top = window.scrollY || document.documentElement.scrollTop - if (top >= max - 2) break + while (performance.now() - started < 2000 && top() < max() - 2) { await new Promise((r) => window.setTimeout(r, 32)) } - window.scrollTo({ top: max }) + scroll("instant") } /** Scroll to the end, then play the stretch on matching footers. */ @@ -66,55 +108,473 @@ export async function playStretchyFooterDemo( detail: StretchyFooterPlayDetail = {} ) { if (typeof window === "undefined") return - await scrollToEnd(detail.scrollRoot) - window.dispatchEvent( - new CustomEvent(STRETCHY_FOOTER_PLAY, { - detail, - }) + new CustomEvent(STRETCHY_FOOTER_PLAY, { detail }) + ) + // Travel out, hold, spring home. + await new Promise((r) => + window.setTimeout(r, (detail.holdMs ?? 700) + 900) ) - - const hold = detail.holdMs ?? 700 - // Travel out + hold + spring home - await new Promise((r) => window.setTimeout(r, hold + 900)) } +/** Bell curve across the columns: tall in the middle, short at the edges. */ export function columnScale(index: number, count: number): number { if (count <= 1) return 1 const t = (index / (count - 1)) * 2 - 1 return Math.exp(-t * t * 2.2) } -/** Stable rgba() for SSR — avoids hex/rgb serialization mismatches. */ -export function hexToRgba(hex: string, alpha: number): string { - const raw = hex.replace("#", "") - const full = - raw.length === 3 - ? raw - .split("") - .map((c) => c + c) - .join("") - : raw.slice(0, 6) - const r = Number.parseInt(full.slice(0, 2), 16) - const g = Number.parseInt(full.slice(2, 4), 16) - const b = Number.parseInt(full.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` +/** Overscroll distance → stretch. Linear at first, never past `max`. */ +export function rubberBand(distance: number, max: number): number { + if (max <= 0 || distance <= 0) return 0 + return max * (1 - 1 / ((distance * RUBBER) / max + 1)) +} + +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) +} + +/** Wheel delta in px, whatever unit the device reports in. */ +function wheelPixels(event: WheelEvent) { + if (event.deltaMode === 1) return event.deltaY * 16 + if (event.deltaMode === 2) return event.deltaY * window.innerHeight + return event.deltaY +} + +/** + * True when something between `target` and the scroller can still scroll + * in that direction — a code block or a menu gets the wheel, not us. + */ +function nestedCanScroll( + target: EventTarget | null, + root: Element, + dy: number +) { + let el = target instanceof Element ? target : null + while (el && el !== root && el !== document.documentElement) { + if (el.scrollHeight > el.clientHeight + 1) { + const overflow = getComputedStyle(el).overflowY + if (overflow === "auto" || overflow === "scroll") { + const room = + dy > 0 + ? el.scrollHeight - el.clientHeight - el.scrollTop + : el.scrollTop + if (room > 1) return true + } + } + el = el.parentElement + } + return false } -export function applyResistance(current: number, delta: number, max: number): number { - if (max <= 0) return 0 - const t = Math.min(1, Math.max(0, current / max)) - const falloff = (1 - t) * (1 - t * 0.35) - const pop = current < 40 ? POP_BOOST : 1 - return Math.min(max, Math.max(0, current + delta * PULL_GAIN * falloff * pop)) +/** A vertical strip of `color`: solid at the floor, fading out at the top. */ +function makeStrip(color: string) { + const strip = document.createElement("canvas") + strip.width = 1 + strip.height = 64 + const g = strip.getContext("2d") + if (!g) return strip + g.fillStyle = color + g.fillRect(0, 0, 1, 64) + const fade = g.createLinearGradient(0, 0, 0, 64) + fade.addColorStop(0, "rgba(0,0,0,0)") + fade.addColorStop(0.55, "rgba(0,0,0,0.6)") + fade.addColorStop(1, "rgba(0,0,0,1)") + g.globalCompositeOperation = "destination-in" + g.fillStyle = fade + g.fillRect(0, 0, 1, 64) + return strip } -export function elementAtBottom(el: HTMLElement, slack = 1): boolean { - return el.scrollTop + el.clientHeight >= el.scrollHeight - slack +/** Half a white radial glow, flat side down, for the floor bloom. */ +function makeBloom() { + const bloom = document.createElement("canvas") + bloom.width = 128 + bloom.height = 64 + const g = bloom.getContext("2d") + if (!g) return bloom + const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64) + grad.addColorStop(0, "rgba(255,255,255,1)") + grad.addColorStop(1, "rgba(255,255,255,0)") + g.fillStyle = grad + g.fillRect(0, 0, 128, 64) + return bloom } -export function windowAtBottom(slack = 1): boolean { - const doc = document.documentElement - return window.scrollY + window.innerHeight >= doc.scrollHeight - slack +type Spring = { x: number; v: number; target: number } + +function stepSpring(s: Spring, k: number, c: number, dt: number) { + const accel = (-k * (s.x - s.target) - c * s.v) / MASS + s.v += accel * dt + s.x += s.v * dt +} + +const atRest = (s: Spring) => + Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST + +/** + * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the + * content lifts on a spring while an aurora of blurred columns stretches up + * from the floor; each column has its own spring, so the edge wobbles as it + * snaps back. Idle unless stretched. + */ +export function createStretchyFooter( + mount: StretchyFooterMount, + initial: StretchyFooterOptions = {} +): StretchyFooterInstance | null { + const { canvas, scroller } = mount + const ctx = canvas.getContext("2d") + if (!ctx) return null + + let options: StretchyFooterOptions = { ...initial } + const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000) + const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64) + const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1) + const stiffnessOf = () => + numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000) + const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400) + const countOf = () => + Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 8, 96)) + + /** Overscroll the user has put in, in px. The stretch is its rubber band. */ + let distance = 0 + /** `play()` holds the stretch here, overriding the gesture. */ + let forced: number | null = null + const lift: Spring = { x: 0, v: 0, target: 0 } + let columns: (Spring & { share: number })[] = [] + let strips: HTMLCanvasElement[] = [] + const bloom = makeBloom() + + const rebuildColumns = () => { + const count = countOf() + const target = lift.target + columns = Array.from({ length: count }, (_, i) => { + const share = Math.max(MIN_COLUMN, columnScale(i, count)) + return { x: lift.x * share, v: 0, target: target * share, share } + }) + } + const rebuildStrips = () => { + const colors = options.colors?.length ? options.colors : DEFAULT_COLORS + strips = colors.map(makeStrip) + } + + const size = { w: 0, pad: 0, scale: 1 } + const layout = () => { + const parent = canvas.parentElement + const blur = blurOf() + const max = maxOf() + // Room past the edges, so the blur doesn't fade the sides and floor. + const pad = Math.ceil(blur * 2) + // Blurred anyway — half resolution is plenty and half the fill. + const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2) + size.w = (parent?.clientWidth ?? 0) + pad * 2 + size.pad = pad + size.scale = scale + canvas.width = Math.max(1, Math.ceil(size.w * scale)) + canvas.height = Math.max(1, Math.ceil((max + pad) * scale)) + Object.assign(canvas.style, { + position: "absolute", + left: `${-pad}px`, + bottom: `${-pad}px`, + width: `${size.w}px`, + height: `${max + pad}px`, + filter: blur > 0 ? `blur(${blur}px)` : "", + pointerEvents: "none", + }) + draw() + } + + // The page element that rides the stretch. + let lifted: HTMLElement | null = null + let liftedTransform = "" + const contentEl = () => { + const content = mount.content + if (!content) return null + if (typeof content !== "string") return content + if (lifted?.isConnected && lifted.matches(content)) return lifted + const scope = scroller instanceof HTMLElement ? scroller : document + return ( + scope.querySelector(content) ?? + document.querySelector(content) + ) + } + const liftContent = (y: number) => { + const el = contentEl() + if (el !== lifted) { + if (lifted) lifted.style.transform = liftedTransform + lifted = el + liftedTransform = el?.style.transform ?? "" + } + if (!lifted) return + if (y > 0.2) { + lifted.style.transform = `translate3d(0, ${-y}px, 0)` + lifted.style.willChange = "transform" + } else { + lifted.style.transform = liftedTransform + lifted.style.willChange = "" + } + } + + function draw() { + const stretch = Math.max(0, lift.x) + liftContent(stretch) + const max = maxOf() + const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2) + canvas.style.visibility = visible ? "visible" : "hidden" + if (!visible) return + + const { w, pad, scale } = size + const floor = max * scale + const bottom = canvas.height + ctx!.setTransform(1, 0, 0, 1, 0, 0) + ctx!.clearRect(0, 0, canvas.width, canvas.height) + ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1) + + // Columns span the visible width; the outer two run on into the pad. + const count = columns.length + const inner = (w - pad * 2) * scale + const edge = (i: number) => + i <= 0 + ? 0 + : i >= count + ? canvas.width + : Math.round(pad * scale + (inner * i) / count) + for (let i = 0; i < count; i++) { + const h = Math.max(0, columns[i]!.x) * scale + if (h < 0.5) continue + const x0 = edge(i) + const x1 = edge(i + 1) + const strip = strips[i % strips.length]! + ctx!.drawImage(strip, x0, floor - h, x1 - x0, h) + ctx!.drawImage(strip, 0, 63, 1, 1, x0, floor, x1 - x0, bottom - floor) + } + + const glow = glowOf() + if (glow > 0) { + const rx = inner * 0.49 + const ry = stretch * 0.35 * scale + ctx!.globalAlpha *= glow + ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry) + } + ctx!.globalAlpha = 1 + } + + let raf = 0 + let last = 0 + const retarget = () => { + const max = maxOf() + const target = forced ?? rubberBand(distance, max) + lift.target = target + for (const column of columns) column.target = target * column.share + if (!raf) { + last = performance.now() + raf = requestAnimationFrame(frame) + } + } + + function frame(now: number) { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const k = stiffnessOf() + const c = dampingOf() + const steps = Math.max(1, Math.ceil(dt / STEP)) + for (let n = 0; n < steps; n++) { + stepSpring(lift, k, c, dt / steps) + for (const column of columns) { + // Short edge columns are softer and bouncier than the middle, so the + // top edge ripples out from the centre. + const ck = k * (0.6 + 0.4 * column.share) + stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps) + } + } + const settled = atRest(lift) && columns.every(atRest) + if (settled) { + lift.x = lift.target + lift.v = 0 + for (const column of columns) { + column.x = column.target + column.v = 0 + } + } + draw() + if (!settled) raf = requestAnimationFrame(frame) + } + + // Gestures. + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + let reduce = mqReduce.matches + let releaseTimer = 0 + let holdTimer = 0 + let lastWheel = 0 + let lastDelta = 0 + let coasting = 0 + let cooling = false + let lastTouchY = 0 + + const root = + scroller instanceof HTMLElement ? scroller : document.documentElement + const atBottom = () => + root.scrollTop + root.clientHeight >= root.scrollHeight - 2 + + const release = () => { + window.clearTimeout(releaseTimer) + releaseTimer = 0 + if (distance <= 0) return + distance = 0 + cooling = true + retarget() + } + const stopPlay = () => { + if (forced === null) return + window.clearTimeout(holdTimer) + forced = null + retarget() + } + + const onWheel = (event: WheelEvent) => { + if (reduce || event.ctrlKey) return + const dy = wheelPixels(event) + if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return + stopPlay() + const now = performance.now() + const gap = now - lastWheel + lastWheel = now + + if (distance > 0) { + event.preventDefault() + // A trackpad coasting out: deltas shrink every event. Treat it as + // letting go instead of holding the stretch until momentum ends. + coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0 + lastDelta = dy + if (coasting >= COAST_EVENTS) { + release() + return + } + distance = Math.max(0, distance + dy) + retarget() + window.clearTimeout(releaseTimer) + if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS) + return + } + + // Momentum still arriving after a release shouldn't pull again. + if (cooling) { + if (gap < MOMENTUM_GAP_MS) return + cooling = false + } + if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) { + event.preventDefault() + distance = dy + lastDelta = dy + coasting = 0 + retarget() + releaseTimer = window.setTimeout(release, RELEASE_MS) + } + } + + const onTouchStart = (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + stopPlay() + cooling = false + lastTouchY = touch.clientY + } + const onTouchMove = (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch || reduce) return + const dy = lastTouchY - touch.clientY + lastTouchY = touch.clientY + if ( + distance > 0 || + (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) + ) { + if (event.cancelable) event.preventDefault() + distance = Math.max(0, distance + dy) + retarget() + } + } + const onTouchEnd = () => release() + + const onPlayEvent = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {} + // Scoped footers only answer their own demo; unscoped ones, broadcasts. + if ((detail.target ?? null) !== (options.demoId ?? null)) return + instance.play(detail) + } + + const onReduce = () => { + reduce = mqReduce.matches + if (reduce) release() + } + + const target: HTMLElement | Window = scroller + const prevOverscroll = root.style.overscrollBehaviorY + // The browser's own rubber band would fight ours. + root.style.overscrollBehaviorY = "none" + target.addEventListener("wheel", onWheel as EventListener, { passive: false }) + target.addEventListener("touchstart", onTouchStart as EventListener, { + passive: true, + }) + target.addEventListener("touchmove", onTouchMove as EventListener, { + passive: false, + }) + target.addEventListener("touchend", onTouchEnd) + target.addEventListener("touchcancel", onTouchEnd) + window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent) + mqReduce.addEventListener("change", onReduce) + const ro = new ResizeObserver(layout) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + rebuildStrips() + rebuildColumns() + layout() + + const instance: StretchyFooterInstance = { + setOptions(next) { + const prevColors = options.colors + const prevMax = maxOf() + const prevBlur = blurOf() + options = { ...options, ...next } + if (options.colors !== prevColors) rebuildStrips() + if (countOf() !== columns.length) rebuildColumns() + if (maxOf() !== prevMax || blurOf() !== prevBlur) layout() + if (forced !== null) forced = Math.min(forced, maxOf()) + retarget() + }, + play({ amount = 0.82, holdMs = 700 } = {}) { + window.clearTimeout(releaseTimer) + window.clearTimeout(holdTimer) + distance = 0 + forced = maxOf() * clamp(amount, 0, 1) + retarget() + holdTimer = window.setTimeout(stopPlay, holdMs) + }, + destroy() { + cancelAnimationFrame(raf) + raf = 0 + window.clearTimeout(releaseTimer) + window.clearTimeout(holdTimer) + root.style.overscrollBehaviorY = prevOverscroll + target.removeEventListener("wheel", onWheel as EventListener) + target.removeEventListener("touchstart", onTouchStart as EventListener) + target.removeEventListener("touchmove", onTouchMove as EventListener) + target.removeEventListener("touchend", onTouchEnd) + target.removeEventListener("touchcancel", onTouchEnd) + window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent) + mqReduce.removeEventListener("change", onReduce) + ro.disconnect() + if (lifted) { + lifted.style.transform = liftedTransform + lifted.style.willChange = "" + } + }, + } + return instance } diff --git a/registry/stretchy-footer/stretchy-footer.svelte b/registry/stretchy-footer/stretchy-footer.svelte index 64336d2..91f7b58 100644 --- a/registry/stretchy-footer/stretchy-footer.svelte +++ b/registry/stretchy-footer/stretchy-footer.svelte @@ -2,30 +2,25 @@ {#snippet aurora()} @@ -396,42 +128,18 @@ class="pointer-events-none absolute inset-x-0 bottom-0 z-20 overflow-hidden" style="height: {maxStretch}px;" > -
-
- {#each bars as bar (bar.key)} -
- {/each} -
- -
-
+
{/snippet} {#if windowScroll} {#if mounted} +
{@render aurora()} @@ -440,27 +148,27 @@ {:else if scrollEl}
{@render aurora()}
{:else} +
- {@render children?.()} +
+ {@render children?.()} +
{@render aurora()}
diff --git a/registry/stretchy-footer/stretchy-footer.tsx b/registry/stretchy-footer/stretchy-footer.tsx index 49fc4a2..08db1c5 100644 --- a/registry/stretchy-footer/stretchy-footer.tsx +++ b/registry/stretchy-footer/stretchy-footer.tsx @@ -2,48 +2,46 @@ import { useEffect, - useMemo, useRef, - useState, + useSyncExternalStore, type ReactNode, type RefObject, } from "react" import { createPortal } from "react-dom" -import { - motion, - useMotionValue, - useMotionValueEvent, - useReducedMotion, - useSpring, - useTransform, -} from "motion/react" import { cn } from "@/lib/utils" import { - applyResistance, - columnScale, + createStretchyFooter, DEFAULT_BLUR, DEFAULT_COLORS, DEFAULT_COLUMNS, + DEFAULT_DAMPING, DEFAULT_GLOW, - elementAtBottom, - hexToRgba, - POP_BOOST, - PULL_GAIN, - STRETCHY_FOOTER_PLAY, - WHEEL_IDLE_MS, - windowAtBottom, - type StretchyFooterPlayDetail, + DEFAULT_MAX_STRETCH, + DEFAULT_STIFFNESS, + type StretchyFooterInstance, + type StretchyFooterOptions, } from "./stretchy-footer-vanilla" export { + DEFAULT_BLUR, + DEFAULT_COLORS, + DEFAULT_COLUMNS, + DEFAULT_DAMPING, + DEFAULT_GLOW, + DEFAULT_MAX_STRETCH, + DEFAULT_STIFFNESS, playStretchyFooterDemo, STRETCHY_FOOTER_PLAY, } from "./stretchy-footer-vanilla" -export type { StretchyFooterPlayDetail } from "./stretchy-footer-vanilla" +export type { + StretchyFooterInstance, + StretchyFooterOptions, + StretchyFooterPlayDetail, +} from "./stretchy-footer-vanilla" -export type StretchyFooterProps = { +export type StretchyFooterProps = StretchyFooterOptions & { className?: string /** * Page content inside the overflow scroller. @@ -52,13 +50,13 @@ export type StretchyFooterProps = { children?: ReactNode /** * External scroll container. When set, this component only paints the - * aurora overlay and binds overscroll listeners to that element. + * aurora overlay and binds overscroll to that element. * When omitted (and `windowScroll` is false), this component *is* the scroller. */ scrollRef?: RefObject /** * Bind overscroll to the window and paint a fixed bottom aurora. - * Use on full pages (e.g. the component docs) instead of a nested scroller. + * Use on full pages instead of a nested scroller. */ windowScroll?: boolean /** @@ -66,37 +64,16 @@ export type StretchyFooterProps = { * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`. */ contentSelector?: string - /** Peak stretch in px. Default `280`. */ - maxStretch?: number - /** Spectrum stops across the aurora columns. */ - colors?: string[] - /** Spring stiffness. Default `380`. */ - stiffness?: number - /** Spring damping. Default `32`. */ - damping?: number - /** How many vertical aurora bars. Default `48`. */ - columns?: number - /** Gaussian blur on the aurora bars, in px. Default `14`. */ - blur?: number - /** White floor bloom opacity (0–1). Default `0.22`. */ - glow?: number - /** Accessible label for the decorative field. */ + /** Accessible label for the scroll region. */ label?: string - /** - * Optional id for docs demos. `playStretchyFooterDemo({ target })` only - * animates footers whose `demoId` matches. - */ - demoId?: string } -type OverscrollTarget = - | { kind: "element"; el: HTMLElement } - | { kind: "window" } +const noopSubscribe = () => () => {} /** * Dia-style stretchy overflow — overscroll past the bottom and a rainbow - * field scaleY-stretches from the floor while the page lifts with it. - * GPU transforms only (no height animation) for a clean, snappy rubber band. + * aurora stretches up from the floor while the page lifts with it, then + * springs back with a wobble. Honors `prefers-reduced-motion`. */ export function StretchyFooter({ className, @@ -104,291 +81,60 @@ export function StretchyFooter({ scrollRef, windowScroll = false, contentSelector = "[data-stretchy-page]", - maxStretch = 280, + maxStretch = DEFAULT_MAX_STRETCH, colors = DEFAULT_COLORS, - stiffness = 380, - damping = 32, + stiffness = DEFAULT_STIFFNESS, + damping = DEFAULT_DAMPING, columns = DEFAULT_COLUMNS, blur = DEFAULT_BLUR, glow = DEFAULT_GLOW, label = "Stretchy overflow", demoId, }: StretchyFooterProps) { - const reduceMotion = useReducedMotion() ?? false - const internalRef = useRef(null) - const pull = useMotionValue(0) - const stretch = useSpring(pull, { - stiffness, - damping, - mass: 0.35, - restDelta: 0.2, - restSpeed: 0.2, - }) - const touchStartY = useRef(0) - const touchStretch0 = useRef(0) - const wheelTimer = useRef | null>(null) - const demoTimer = useRef | null>(null) - const demoPlayingRef = useRef(false) - const rolledEl = useRef(null) - const [mounted, setMounted] = useState(false) - /** User-triggered demo — plays even when prefers-reduced-motion is on. */ - const [demoPlaying, setDemoPlaying] = useState(false) - demoPlayingRef.current = demoPlaying - // Gate on `mounted` so prefers-reduced-motion can't mismatch SSR HTML. - const reduce = mounted && reduceMotion && !demoPlaying - - useEffect(() => { - setMounted(true) - }, []) - - // 0 → 1 progress drives scaleY (compositor-friendly). - const progress = useTransform(stretch, (v) => - maxStretch <= 0 ? 0 : Math.min(1, Math.max(0, v / maxStretch)) + const canvasRef = useRef(null) + const scrollerRef = useRef(null) + const contentRef = useRef(null) + const instanceRef = useRef(null) + // The window aurora portals to , which only exists on the client. + const onClient = useSyncExternalStore( + noopSubscribe, + () => true, + () => false ) - const pageY = useTransform(stretch, (v) => -v) - const fieldOpacity = useTransform(progress, [0, 0.08, 0.2], [0, 1, 1]) - - const blurPx = Math.max(0, blur) - const glowAlpha = Math.min(1, Math.max(0, glow)) - - const bars = useMemo(() => { - const count = Math.max(8, Math.min(96, Math.floor(columns))) - return Array.from({ length: count }, (_, i) => { - const color = colors[i % colors.length]! - const heightPct = Math.max(28, columnScale(i, count) * 100).toFixed(2) - return { - key: i, - height: `${heightPct}%`, - backgroundImage: `linear-gradient(to top, ${hexToRgba(color, 1)} 0%, ${hexToRgba(color, 0.6)} 45%, rgba(0, 0, 0, 0) 100%)`, - } - }) - }, [colors, columns]) - - // Lift the page in lockstep with the stretch — translate only, no 3D. - useMotionValueEvent(stretch, "change", (v) => { - if (reduceMotion && !demoPlayingRef.current) return - if (!windowScroll && !scrollRef) return - - if (!rolledEl.current?.isConnected) { - rolledEl.current = document.querySelector( - contentSelector - ) as HTMLElement | null - } - const el = rolledEl.current - if (!el) return - - const y = -Math.max(0, v) - el.style.transformOrigin = "50% 100%" - el.style.willChange = "transform" - el.style.transform = `translate3d(0, ${y}px, 0)` - - if (v < 0.2) { - el.style.transform = "translate3d(0, 0, 0)" - el.style.willChange = "" - } - }) + const portalHost = windowScroll && onClient ? document.body : null useEffect(() => { + const canvas = canvasRef.current + const scroller = windowScroll + ? window + : (scrollRef?.current ?? scrollerRef.current) + if (!canvas || !scroller) return + const content = + windowScroll || scrollRef ? contentSelector : contentRef.current + instanceRef.current = createStretchyFooter( + { canvas, scroller, content }, + { maxStretch, colors, stiffness, damping, columns, blur, glow, demoId } + ) return () => { - const el = rolledEl.current - if (!el) return - el.style.transform = "" - el.style.transformOrigin = "" - el.style.willChange = "" - } - }, []) - - // Always listen for docs "Show effect" — drives pull directly (no fake wheels). - useEffect(() => { - const clearDemoTimer = () => { - if (demoTimer.current) { - clearTimeout(demoTimer.current) - demoTimer.current = null - } - } - - const onPlay = (event: Event) => { - const detail = - (event as CustomEvent).detail ?? {} - if (detail.target != null && detail.target !== demoId) return - // Scoped footers ignore broadcast plays without a target. - if (detail.target == null && demoId != null) return - - const amount = detail.amount ?? 0.82 - const holdMs = detail.holdMs ?? 700 - - if (wheelTimer.current) { - clearTimeout(wheelTimer.current) - wheelTimer.current = null - } - clearDemoTimer() - demoPlayingRef.current = true - setDemoPlaying(true) - pull.set(Math.min(maxStretch, Math.max(0, maxStretch * amount))) - demoTimer.current = setTimeout(() => { - demoTimer.current = null - pull.set(0) - // Wait for the spring to settle before re-honoring reduced motion. - window.setTimeout(() => { - demoPlayingRef.current = false - setDemoPlaying(false) - }, 500) - }, holdMs) - } - - window.addEventListener(STRETCHY_FOOTER_PLAY, onPlay) - return () => { - clearDemoTimer() - window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlay) + instanceRef.current?.destroy() + instanceRef.current = null } - }, [demoId, maxStretch, pull]) + // Options stream in through setOptions; only the wiring remounts. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowScroll, scrollRef, contentSelector, portalHost]) useEffect(() => { - if (reduceMotion) return - - let target: OverscrollTarget | null = null - if (windowScroll) { - target = { kind: "window" } - } else { - const el = scrollRef?.current ?? internalRef.current - if (el) target = { kind: "element", el } - } - if (!target) return - - // Kill browser rubber-band so it doesn't fight ours. - const prevOverscroll = - target.kind === "window" - ? document.documentElement.style.overscrollBehaviorY - : target.el.style.overscrollBehaviorY - if (target.kind === "window") { - document.documentElement.style.overscrollBehaviorY = "none" - } else { - target.el.style.overscrollBehaviorY = "none" - } - - const isAtBottom = () => - target.kind === "window" ? windowAtBottom() : elementAtBottom(target.el) - - const clearWheelTimer = () => { - if (wheelTimer.current) { - clearTimeout(wheelTimer.current) - wheelTimer.current = null - } - } - - const pullTo = (next: number) => { - pull.set(next) - } - - const snapBack = () => { - clearWheelTimer() - pull.set(0) - } - - const onWheel = (event: WheelEvent) => { - if (demoTimer.current) { - clearTimeout(demoTimer.current) - demoTimer.current = null - setDemoPlaying(false) - } - const current = pull.get() - const scrollingDown = event.deltaY > 0 - const scrollingUp = event.deltaY < 0 - - if (current > 0.5 || stretch.get() > 0.5) { - event.preventDefault() - if (scrollingUp) { - const next = Math.max(0, current + event.deltaY * PULL_GAIN) - if (next <= 0.5) { - snapBack() - return - } - pullTo(next) - } else if (scrollingDown) { - pullTo(applyResistance(current, event.deltaY, maxStretch)) - } - clearWheelTimer() - wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS) - return - } - - if (scrollingDown && isAtBottom()) { - event.preventDefault() - pullTo(applyResistance(0, event.deltaY, maxStretch)) - clearWheelTimer() - wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS) - } - } - - const onTouchStart = (event: TouchEvent) => { - const t = event.touches[0] - if (!t) return - if (demoTimer.current) { - clearTimeout(demoTimer.current) - demoTimer.current = null - setDemoPlaying(false) - } - touchStartY.current = t.clientY - touchStretch0.current = pull.get() - } - - const onTouchMove = (event: TouchEvent) => { - const t = event.touches[0] - if (!t) return - const dy = touchStartY.current - t.clientY - const current = pull.get() - - if (current > 0.5 || stretch.get() > 0.5 || (dy > 0 && isAtBottom())) { - if (event.cancelable) event.preventDefault() - const boost = current < 40 ? POP_BOOST : 1 - const fromRest = touchStretch0.current + dy * PULL_GAIN * boost - pullTo(Math.min(maxStretch, Math.max(0, fromRest))) - } - } - - const onTouchEnd = () => { - if (pull.get() > 0.5 || stretch.get() > 0.5) snapBack() - } - - const optsWheel = { passive: false } as const - const optsTouchStart = { passive: true } as const - const optsTouchMove = { passive: false } as const - - if (target.kind === "window") { - window.addEventListener("wheel", onWheel, optsWheel) - window.addEventListener("touchstart", onTouchStart, optsTouchStart) - window.addEventListener("touchmove", onTouchMove, optsTouchMove) - window.addEventListener("touchend", onTouchEnd) - window.addEventListener("touchcancel", onTouchEnd) - } else { - const el = target.el - el.addEventListener("wheel", onWheel, optsWheel) - el.addEventListener("touchstart", onTouchStart, optsTouchStart) - el.addEventListener("touchmove", onTouchMove, optsTouchMove) - el.addEventListener("touchend", onTouchEnd) - el.addEventListener("touchcancel", onTouchEnd) - } - - return () => { - clearWheelTimer() - if (target.kind === "window") { - document.documentElement.style.overscrollBehaviorY = prevOverscroll - window.removeEventListener("wheel", onWheel) - window.removeEventListener("touchstart", onTouchStart) - window.removeEventListener("touchmove", onTouchMove) - window.removeEventListener("touchend", onTouchEnd) - window.removeEventListener("touchcancel", onTouchEnd) - } else { - target.el.style.overscrollBehaviorY = prevOverscroll - const el = target.el - el.removeEventListener("wheel", onWheel) - el.removeEventListener("touchstart", onTouchStart) - el.removeEventListener("touchmove", onTouchMove) - el.removeEventListener("touchend", onTouchEnd) - el.removeEventListener("touchcancel", onTouchEnd) - } - } - }, [reduceMotion, scrollRef, windowScroll, maxStretch, pull, stretch]) + instanceRef.current?.setOptions({ + maxStretch, + colors, + stiffness, + damping, + columns, + blur, + glow, + demoId, + }) + }, [maxStretch, colors, stiffness, damping, columns, blur, glow, demoId]) const aurora = (
- - {/* Single blur layer — cheaper than per-bar filters */} -
- {bars.map((bar) => ( -
- ))} -
- - {/* Soft floor bloom */} -
- +
) if (windowScroll) { - if (!mounted) return null - - // Portal to body so `fixed` stays on the viewport — a transformed - // ancestor (the rolling page) would otherwise pin it to the content. + if (!portalHost) return null + // Portal so `fixed` stays on the viewport — the lifted page is + // transformed and would otherwise pin it to the content. return createPortal(
{aurora}
, - document.body + portalHost ) } @@ -456,7 +169,6 @@ export function StretchyFooter({ return (
- - {children} - +
+ {children} +
+
{aurora}
) diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs index 6cd2f26..799b738 100644 --- a/tests/registry.test.mjs +++ b/tests/registry.test.mjs @@ -137,12 +137,14 @@ test("detectVanillaImports finds every sibling *-vanilla specifier", () => { ]) }) -test("published stretchy-footer-svelte inlines both vanilla engines", () => { +test("published stretchy-footer inlines the shared engine", () => { const dir = join(ROOT, "registry/stretchy-footer") - const out = publishedFileContent(dir, "stretchy-footer.svelte") - assert.doesNotMatch(out, /from\s+["']\.\/[^"']+-vanilla["']/) - assert.match(out, /export function createSpring/) - assert.match(out, /export function applyResistance/) + for (const file of ["stretchy-footer.tsx", "stretchy-footer.svelte"]) { + const out = publishedFileContent(dir, file) + assert.doesNotMatch(out, /from\s+["']\.\/[^"']+-vanilla["']/) + assert.doesNotMatch(out, /from\s+["']motion/) + assert.match(out, /export function createStretchyFooter/) + } }) test("makeStandalone keeps framework imports while dropping two vanilla engines", () => { From 308be77bc28031a90a7e69aba0e3c4251794928f Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:26:06 +0530 Subject: [PATCH 15/49] Give Stretchy Footer the stepped gradient aurora. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine wide columns step down from the middle like a pyramid, and each carries the same top-to-floor gradient — magenta through sunlight to deep navy — squeezed to its own height. colors now lists those stops; the demo palettes follow, and the floor bloom is off by default. --- content/docs/components/stretchy-footer.mdx | 38 +++---- public/r/stretchy-footer-svelte.json | 2 +- public/r/stretchy-footer.json | 2 +- .../stretchy-footer/stretchy-footer-demo.tsx | 48 ++++++-- .../stretchy-footer-vanilla.ts | 106 +++++++++++------- 5 files changed, 124 insertions(+), 72 deletions(-) diff --git a/content/docs/components/stretchy-footer.mdx b/content/docs/components/stretchy-footer.mdx index 419fdd9..23c9acf 100644 --- a/content/docs/components/stretchy-footer.mdx +++ b/content/docs/components/stretchy-footer.mdx @@ -5,7 +5,7 @@ description: Dia-style rubber overscroll — pull past the bottom and a rainbow import { StretchyFooterDemo } from "@/registry/stretchy-footer/stretchy-footer-demo" -Not a scroll-progress footer reveal — an **overflow rubber-band**. In the preview, scroll past the end (or hit Show effect): the page lifts on a spring and an aurora of blurred columns stretches up to fill the gap. Every column has its own spring, so the top edge ripples as it snaps back. Swap palettes, blur, glow, and snap below. +Not a scroll-progress footer reveal — an **overflow rubber-band**. In the preview, scroll past the end (or hit Show effect): the page lifts on a spring and a stepped pyramid of blurred gradient columns (magenta through sunlight to deep navy) stretches up to fill the gap. Every column has its own spring, so the top edge ripples as it snaps back. Swap palettes, blur, glow, and snap below. ## Preview @@ -95,21 +95,21 @@ export function DocsChrome() { ## Props -| Prop | Type | Default | -| --- | --- | --- | -| `children` | `ReactNode` / snippet | — | -| `scrollRef` | `RefObject` | React — self as scroller | -| `scrollEl` | `HTMLElement` | Svelte — self as scroller | -| `windowScroll` | `boolean` | `false` | -| `contentSelector` | `string` | `[data-stretchy-page]` | -| `maxStretch` | `number` | `280` | -| `colors` | `string[]` | spectrum red → pink | -| `stiffness` | `number` | `380` | -| `damping` | `number` | `32` | -| `columns` | `number` | `48` | -| `blur` | `number` | `14` — px | -| `glow` | `number` | `0.22` — floor bloom, 0–1 | -| `label` | `string` | `Stretchy overflow` — scroll region label | -| `demoId` | `string` | — pairs with `playStretchyFooterDemo({ target })` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| Prop | Type | Default | +| ----------------- | -------------------------------- | ------------------------------------------------- | +| `children` | `ReactNode` / snippet | — | +| `scrollRef` | `RefObject` | React — self as scroller | +| `scrollEl` | `HTMLElement` | Svelte — self as scroller | +| `windowScroll` | `boolean` | `false` | +| `contentSelector` | `string` | `[data-stretchy-page]` | +| `maxStretch` | `number` | `280` | +| `colors` | `string[]` | magenta → navy — gradient stops, top to floor | +| `stiffness` | `number` | `380` | +| `damping` | `number` | `32` | +| `columns` | `number` | `9` — steps down from the middle | +| `blur` | `number` | `16` — px | +| `glow` | `number` | `0` — white floor bloom, 0–1 | +| `label` | `string` | `Stretchy overflow` — scroll region label | +| `demoId` | `string` | — pairs with `playStretchyFooterDemo({ target })` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | diff --git a/public/r/stretchy-footer-svelte.json b/public/r/stretchy-footer-svelte.json index c896169..d472ed2 100644 --- a/public/r/stretchy-footer-svelte.json +++ b/public/r/stretchy-footer-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/stretchy-footer/stretchy-footer.svelte", - "content": "\n\n\n\n{#snippet aurora()}\n \n \n
\n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n \n {@render aurora()}\n
\n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n
\n{:else}\n \n \n \n
\n {@render children?.()}\n
\n \n {@render aurora()}\n \n{/if}\n", + "content": "\n\n\n\n{#snippet aurora()}\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n \n
\n {@render children?.()}\n
\n \n {@render aurora()}\n \n{/if}\n", "type": "registry:file", "target": "src/lib/components/ui/stretchy-footer.svelte" } diff --git a/public/r/stretchy-footer.json b/public/r/stretchy-footer.json index 41513f9..e42eeb7 100644 --- a/public/r/stretchy-footer.json +++ b/public/r/stretchy-footer.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/stretchy-footer/stretchy-footer.tsx", - "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_COLORS = [\n \"#FF3B30\",\n \"#FF9500\",\n \"#FFCC00\",\n \"#34C759\",\n \"#00C7BE\",\n \"#32ADE6\",\n \"#007AFF\",\n \"#5856D6\",\n \"#AF52DE\",\n \"#FF2D55\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 48\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 14\n/** White floor bloom opacity (0–1). */\nexport const DEFAULT_GLOW = 0.22\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Shortest column as a share of the stretch. */\nconst MIN_COLUMN = 0.28\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /** Spectrum stops across the aurora columns, any CSS colors. */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns. Default `48`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `14`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0.22`. */\n glow?: number\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/** Bell curve across the columns: tall in the middle, short at the edges. */\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return Math.exp(-t * t * 2.2)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\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/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction — a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\n/** A vertical strip of `color`: solid at the floor, fading out at the top. */\nfunction makeStrip(color: string) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = 64\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n g.fillStyle = color\n g.fillRect(0, 0, 1, 64)\n const fade = g.createLinearGradient(0, 0, 0, 64)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(0.55, \"rgba(0,0,0,0.6)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, 64)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while an aurora of blurred columns stretches up\n * from the floor; each column has its own spring, so the edge wobbles as it\n * snaps back. Idle unless stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 8, 96))\n\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strips: HTMLCanvasElement[] = []\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = Math.max(MIN_COLUMN, columnScale(i, count))\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrips = () => {\n const colors = options.colors?.length ? options.colors : DEFAULT_COLORS\n strips = colors.map(makeStrip)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway — half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(stretch)\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n const strip = strips[i % strips.length]!\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n ctx!.drawImage(strip, 0, 63, 1, 1, x0, floor, x1 - x0, bottom - floor)\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const max = maxOf()\n const target = forced ?? rubberBand(distance, max)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrips()\n rebuildColumns()\n layout()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrips()\n if (countOf() !== columns.length) rebuildColumns()\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n { maxStretch, colors, stiffness, damping, columns, blur, glow, demoId }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n demoId,\n })\n }, [maxStretch, colors, stiffness, damping, columns, blur, glow, demoId])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport — the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", + "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\n/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */\nexport const DEFAULT_COLORS = [\n \"#FF1AE6\",\n \"#FF2E5E\",\n \"#FF8A1A\",\n \"#FFC81F\",\n \"#E2E6EC\",\n \"#8DB6E6\",\n \"#2F74DC\",\n \"#1459E3\",\n \"#1C2566\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 9\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 16\n/** White floor bloom opacity (0–1). Off by default. */\nexport const DEFAULT_GLOW = 0\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Edge columns stand this share of the centre one; the rest step evenly. */\nconst EDGE_COLUMN = 0.55\n/** The top of each column fades in over this share of its height. */\nconst TOP_FADE = 0.12\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /**\n * Gradient stops from the top of each column down to the floor, any CSS\n * colors. Every column carries the whole gradient, squeezed to its height.\n */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns, stepping down from the middle. Default `9`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `16`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0`. */\n glow?: number\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/** Stepped pyramid: the middle column is tallest, each step out is shorter. */\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return 1 - (1 - EDGE_COLUMN) * Math.abs(t)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\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/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction — a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\nconst STRIP = 256\n\n/**\n * One column's gradient, `colors` spread from just under the top to the\n * floor, with the top fading in. Columns stretch it to their own height.\n */\nfunction makeStrip(colors: string[]) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = STRIP\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n const bands = g.createLinearGradient(0, 0, 0, STRIP)\n colors.forEach((color, i) => {\n const at = colors.length > 1 ? i / (colors.length - 1) : 1\n bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color)\n })\n g.fillStyle = bands\n g.fillRect(0, 0, 1, STRIP)\n const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, STRIP)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while a stepped pyramid of blurred gradient\n * columns stretches up from the floor; each column has its own spring, so the edge wobbles as it\n * snaps back. Idle unless stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96))\n\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strip = document.createElement(\"canvas\")\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = columnScale(i, count)\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrip = () => {\n strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway — half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(stretch)\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n ctx!.drawImage(\n strip,\n 0,\n STRIP - 1,\n 1,\n 1,\n x0,\n floor,\n x1 - x0,\n bottom - floor\n )\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const max = maxOf()\n const target = forced ?? rubberBand(distance, max)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrip()\n rebuildColumns()\n layout()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrip()\n if (countOf() !== columns.length) rebuildColumns()\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n { maxStretch, colors, stiffness, damping, columns, blur, glow, demoId }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n demoId,\n })\n }, [maxStretch, colors, stiffness, damping, columns, blur, glow, demoId])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport — the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/stretchy-footer.tsx" } diff --git a/registry/stretchy-footer/stretchy-footer-demo.tsx b/registry/stretchy-footer/stretchy-footer-demo.tsx index 057a728..edd3fb2 100644 --- a/registry/stretchy-footer/stretchy-footer-demo.tsx +++ b/registry/stretchy-footer/stretchy-footer-demo.tsx @@ -24,10 +24,40 @@ import { const PREVIEW_DEMO_ID = "stretchy-footer-preview" -const SPECTRUM = ["#FF3B30", "#FFCC00", "#34C759", "#007AFF", "#AF52DE"] -const SUNSET = ["#FF4D00", "#FF8A5B", "#FFC857", "#E63946", "#9B2226"] -const OCEAN = ["#012A4A", "#01497C", "#2A9D8F", "#48CAE4", "#90E0EF"] -const NEON = ["#F72585", "#B5179E", "#7209B7", "#4361EE", "#4CC9F0"] +// Gradient stops, top of each column to the floor — nine, like the default. +const SUNSET = [ + "#FFE8A3", + "#FFD166", + "#FFA94D", + "#FF7B3D", + "#FF3D6E", + "#D6246E", + "#B5179E", + "#3A0CA3", + "#10002B", +] +const OCEAN = [ + "#F1FCFD", + "#CAF0F8", + "#90E0EF", + "#48CAE4", + "#00B4D8", + "#0096C7", + "#0077B6", + "#023E8A", + "#03045E", +] +const NEON = [ + "#B8F2FF", + "#4CC9F0", + "#4895EF", + "#4361EE", + "#7209B7", + "#B5179E", + "#F72585", + "#FF9E00", + "#1A0B2E", +] function norm(hex: string) { return hex.trim().toUpperCase() @@ -46,7 +76,7 @@ export function StretchyFooterDemo() { const defaults = useMemo( () => ({ - colors: SPECTRUM, + colors: DEFAULT_COLORS, maxStretch: 220, columns: DEFAULT_COLUMNS, stiffness: DEFAULT_STIFFNESS, @@ -146,7 +176,7 @@ export function StretchyFooterDemo() { updateProp("colors", colors)} /> updateProp("columns", v)} /> { + const at = colors.length > 1 ? i / (colors.length - 1) : 1 + bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color) + }) + g.fillStyle = bands + g.fillRect(0, 0, 1, STRIP) + const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE) fade.addColorStop(0, "rgba(0,0,0,0)") - fade.addColorStop(0.55, "rgba(0,0,0,0.6)") fade.addColorStop(1, "rgba(0,0,0,1)") g.globalCompositeOperation = "destination-in" g.fillStyle = fade - g.fillRect(0, 0, 1, 64) + g.fillRect(0, 0, 1, STRIP) return strip } @@ -220,8 +234,8 @@ const atRest = (s: Spring) => /** * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the - * content lifts on a spring while an aurora of blurred columns stretches up - * from the floor; each column has its own spring, so the edge wobbles as it + * content lifts on a spring while a stepped pyramid of blurred gradient + * columns stretches up from the floor; each column has its own spring, so the edge wobbles as it * snaps back. Idle unless stretched. */ export function createStretchyFooter( @@ -240,7 +254,7 @@ export function createStretchyFooter( numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000) const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400) const countOf = () => - Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 8, 96)) + Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96)) /** Overscroll the user has put in, in px. The stretch is its rubber band. */ let distance = 0 @@ -248,20 +262,19 @@ export function createStretchyFooter( let forced: number | null = null const lift: Spring = { x: 0, v: 0, target: 0 } let columns: (Spring & { share: number })[] = [] - let strips: HTMLCanvasElement[] = [] + let strip = document.createElement("canvas") const bloom = makeBloom() const rebuildColumns = () => { const count = countOf() const target = lift.target columns = Array.from({ length: count }, (_, i) => { - const share = Math.max(MIN_COLUMN, columnScale(i, count)) + const share = columnScale(i, count) return { x: lift.x * share, v: 0, target: target * share, share } }) } - const rebuildStrips = () => { - const colors = options.colors?.length ? options.colors : DEFAULT_COLORS - strips = colors.map(makeStrip) + const rebuildStrip = () => { + strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS) } const size = { w: 0, pad: 0, scale: 1 } @@ -350,9 +363,18 @@ export function createStretchyFooter( if (h < 0.5) continue const x0 = edge(i) const x1 = edge(i + 1) - const strip = strips[i % strips.length]! ctx!.drawImage(strip, x0, floor - h, x1 - x0, h) - ctx!.drawImage(strip, 0, 63, 1, 1, x0, floor, x1 - x0, bottom - floor) + ctx!.drawImage( + strip, + 0, + STRIP - 1, + 1, + 1, + x0, + floor, + x1 - x0, + bottom - floor + ) } const glow = glowOf() @@ -532,7 +554,7 @@ export function createStretchyFooter( const ro = new ResizeObserver(layout) if (canvas.parentElement) ro.observe(canvas.parentElement) - rebuildStrips() + rebuildStrip() rebuildColumns() layout() @@ -542,7 +564,7 @@ export function createStretchyFooter( const prevMax = maxOf() const prevBlur = blurOf() options = { ...options, ...next } - if (options.colors !== prevColors) rebuildStrips() + if (options.colors !== prevColors) rebuildStrip() if (countOf() !== columns.length) rebuildColumns() if (maxOf() !== prevMax || blurOf() !== prevBlur) layout() if (forced !== null) forced = Math.min(forced, maxOf()) From cf1c11f3521eb1060e4dd018f7d776667efe623a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:36:06 +0530 Subject: [PATCH 16/49] Rebuild Tangle Footer as tangled text vines. Vines run the width of the footer and curl into loops off a wandering baseline (a rolling pen, sized and curled by slow noise). Each is cut into short pieces with random stacking and a gap cut around every piece, so vines weave over and under each other and themselves. The copy runs along the ribbons as a marquee, and the vines grow in when first seen. Drawn on one canvas with cached paths; drops motion, adds vines, speed, thickness and theme. --- content/docs/components/tangle-footer.mdx | 34 +- public/r/registry.json | 7 +- public/r/tangle-footer-svelte.json | 4 +- public/r/tangle-footer.json | 7 +- registry/tangle-footer/registry.json | 5 +- registry/tangle-footer/tangle-footer-demo.tsx | 41 +- .../tangle-footer/tangle-footer-vanilla.ts | 581 ++++++++++++++---- registry/tangle-footer/tangle-footer.svelte | 188 ++---- registry/tangle-footer/tangle-footer.tsx | 221 +++---- 9 files changed, 679 insertions(+), 409 deletions(-) diff --git a/content/docs/components/tangle-footer.mdx b/content/docs/components/tangle-footer.mdx index bf45a18..1805579 100644 --- a/content/docs/components/tangle-footer.mdx +++ b/content/docs/components/tangle-footer.mdx @@ -1,11 +1,11 @@ --- title: Tangle Footer -description: Five nested circular text rings — concentric semicircle loops with smooth marquee copy on slim ribbons. Theme-aware light and dark. +description: Tangled text vines — ribbons curl into loops and weave over and under each other while your copy runs along them. --- import { TangleFooterDemo } from "@/registry/tangle-footer/tangle-footer-demo" -Five concentric rings as an **upper semicircle** — outer diameter spans the parent width (`max-w-7xl`) when height is left at the default (half width). A shorter `height` scales the nest down so the arches stay complete. Rings rotate for a continuous marquee. Colors follow the site theme (`html.dark`) unless you override them. Press `d` to toggle. +Vines run the width of the footer, curling into loops of every size off a wandering baseline. Each one is cut into short pieces with a random stacking order, so the vines pass over and under each other — and themselves — with a thin gap where they cross. Your phrases run along the ribbons as a slow marquee, through every loop. The vines grow in the first time the footer is seen. Change **Seed** for a new tangle. Press `d` to toggle the theme. ## Preview @@ -21,7 +21,7 @@ Or install straight from the public GitHub source registry: ## Usage -Wrap in `max-w-7xl` (or any width constraint). The outer semicircle fills that width; pass phrases via `lines` (one per ring). +Drop it at the bottom of the page. It fills the width it's given at `height` px tall; pass phrases via `lines` and each vine picks one or two. @@ -66,14 +66,20 @@ export function SiteFooter() { ## Props -| Prop | Type | Default | -| ------------ | ---------- | ----------------------------------- | -| `lines` | `string[]` | built-in phrases | -| `ribbon` | `string` | theme (`#141414` / `#E8E4DC`) | -| `textColor` | `string` | theme (`#F4F0E8` / `#161616`) | -| `background` | `string` | theme (`#EFEAE2` / `#121210`) | -| `height` | `number` | half of measured width (semicircle) | -| `seed` | `number` | `23` | -| `label` | `string` | `Site footer` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| Prop | Type | Default | +| ------------ | ----------------------------- | -------------------------------------- | +| `lines` | `string[]` | built-in phrases | +| `ribbon` | `string` | theme (`#141414` / `#E8E4DC`) | +| `textColor` | `string` | theme (`#F4F0E8` / `#161616`) | +| `background` | `string` | theme (`#EFEAE2` / `#121210`) | +| `height` | `number` | `320` — px | +| `vines` | `number` | `4` — `1`–`12` | +| `speed` | `number` | `36` — px per second along the vines | +| `thickness` | `number` | scales with width — ribbon width in px | +| `seed` | `number` | `23` — a new seed is a new tangle | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | +| `label` | `string` | `Site footer` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | + +`prefers-reduced-motion: reduce` shows the grown tangle with the copy standing still. The animation also pauses while the footer is off-screen or the tab is hidden. diff --git a/public/r/registry.json b/public/r/registry.json index 6b3e7a1..ac43b08 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -351,10 +351,7 @@ { "name": "tangle-footer", "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", - "dependencies": [ - "motion" - ], + "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", "files": [ { "path": "registry/tangle-footer/tangle-footer.tsx", @@ -367,7 +364,7 @@ { "name": "tangle-footer-svelte", "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", + "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", "dependencies": [], "files": [ { diff --git a/public/r/tangle-footer-svelte.json b/public/r/tangle-footer-svelte.json index 3d5e576..95df94e 100644 --- a/public/r/tangle-footer-svelte.json +++ b/public/r/tangle-footer-svelte.json @@ -2,12 +2,12 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tangle-footer-svelte", "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", + "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", "dependencies": [], "files": [ { "path": "registry/tangle-footer/tangle-footer.svelte", - "content": "\n\n\n\n 0 ? `${bandHeight}px` : undefined}\n style:aspect-ratio={height == null ? \"2 / 1\" : undefined}\n>\n {#if showSvg}\n \n \n {#each rings as ring, i}\n \n {/each}\n \n\n {#each rings as ring, i}\n {@const pathId = `${uid}-path-${i}`}\n \n \n \n {ring.text}\n \n \n {/each}\n \n {/if}\n\n

{lines.join(\" \")}

\n\n\n\n", + "content": "\n\n\n\n\n \n

{lines.join(\" \")}

\n\n", "type": "registry:file", "target": "src/lib/components/ui/tangle-footer.svelte" } diff --git a/public/r/tangle-footer.json b/public/r/tangle-footer.json index 50f6ffa..5c38bd4 100644 --- a/public/r/tangle-footer.json +++ b/public/r/tangle-footer.json @@ -2,14 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tangle-footer", "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", - "dependencies": [ - "motion" - ], + "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", "files": [ { "path": "registry/tangle-footer/tangle-footer.tsx", - "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_LINES = [\n \"Ship something opinionated — less boilerplate, clearer decisions.\",\n \"Knows what’s going on. Can you check in with them and see what’s next.\",\n \"The new timeline should be ready by Friday, although it’s probably going to slip.\",\n \"Open the docs, grab a component, and make it yours in the codebase.\",\n \"Radiant lines, shader wash, gooey picker — install what you need and move.\",\n]\n\nexport type Ring = {\n d: string\n cx: number\n cy: number\n strokeWidth: number\n fontSize: number\n /** Text repeated enough to blanket the full circumference. */\n text: string\n /** Seconds for one full revolution. */\n duration: number\n /** Fraction of a turn (0–1) to offset the starting rotation. */\n phase: number\n reverse: boolean\n}\n\nexport type TangleFooterOptions = {\n /** Phrases sampled randomly onto rings. */\n lines?: string[]\n /**\n * Ribbon (stroke) color. Omit for theme-aware defaults\n * (near-black in light, soft cream in dark).\n */\n ribbon?: string\n /**\n * Text fill on the ribbons. Omit for theme-aware defaults\n * (cream in light, near-black in dark).\n */\n textColor?: string\n /**\n * Field behind the rings. Omit for theme-aware defaults\n * (warm cream in light, near-black in dark). Pass `\"transparent\"`\n * when the parent already paints the stage.\n */\n background?: string\n /**\n * Band height in px. Omit to use half the measured width\n * (upper semicircle of a full-width nest). When set shorter\n * than that, the nest scales down to fit so rings stay intact.\n */\n height?: number\n /** Seed for random line / marquee assignment across rings. */\n seed?: number\n /** Accessible label. */\n label?: string\n}\n\nexport const RING_COUNT = 5\n\nexport const K = 0.5522847498\nexport const STROKE = 28\n\n/** Mulberry32 — deterministic PRNG so rings stay stable across re-renders. */\nexport function mulberry32(seed: number) {\n let t = seed >>> 0\n return () => {\n t += 0x6d2b79f5\n let r = Math.imul(t ^ (t >>> 15), 1 | t)\n r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)\n return ((r ^ (r >>> 14)) >>> 0) / 4294967296\n }\n}\n\n/** Perfect circle as four cubics (container clips to the upper half). */\nexport function circlePath(cx: number, cy: number, r: number): string {\n const o = r * K\n return [\n `M ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`,\n `C ${(cx + r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx + o).toFixed(1)} ${(cy + r).toFixed(1)} ${cx.toFixed(1)} ${(cy + r).toFixed(1)}`,\n `C ${(cx - o).toFixed(1)} ${(cy + r).toFixed(1)} ${(cx - r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx - r).toFixed(1)} ${cy.toFixed(1)}`,\n `C ${(cx - r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx - o).toFixed(1)} ${(cy - r).toFixed(1)} ${cx.toFixed(1)} ${(cy - r).toFixed(1)}`,\n `C ${(cx + o).toFixed(1)} ${(cy - r).toFixed(1)} ${(cx + r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`,\n ].join(\" \")\n}\n\n/**\n * Tile copy so the ring is fully blanketed with no visible gap.\n * Rotation makes the loop seamless, so we only need to cover the\n * circumference — no exact tile-width matching required.\n */\nexport function buildRingCopy(\n line: string,\n other: string,\n mix: boolean,\n circumference: number,\n fontSize: number\n): string {\n const unit = mix\n ? `${line} · ${other} · `\n : `${line} · ${line} · ${other} · `\n // Bold + letter-spacing runs wider than 0.5em — over-estimate so we never underfill.\n const unitWidth = Math.max(unit.length * fontSize * 0.72, 1)\n const repeats = Math.max(3, Math.ceil(circumference / unitWidth) + 1)\n return unit.repeat(repeats)\n}\n\n/**\n * Five concentric circles nested as an upper semicircle.\n * Outer radius fits both width and band height so a short band\n * still shows complete arches instead of an equatorial clip.\n */\nexport function buildRings(\n width: number,\n bandHeight: number,\n lines: string[],\n seed: number\n): Ring[] {\n const rand = mulberry32(seed)\n const cx = width / 2\n const cy = bandHeight\n const strokePad = STROKE / 2 + 2\n // Full-width nest needs height ≈ width/2; if the band is shorter,\n // shrink the nest so the upper semicircle stays fully visible.\n const outer = Math.max(\n Math.min(width / 2 - strokePad, bandHeight - strokePad),\n STROKE * 4\n )\n const radii = Array.from(\n { length: RING_COUNT },\n (_, i) => (outer * (i + 1)) / RING_COUNT\n )\n\n const fontSize = Math.min(24, Math.max(16, width * 0.022))\n const pool = lines.length > 0 ? lines : DEFAULT_LINES\n\n return radii.map((r, i) => {\n const line = pool[Math.floor(rand() * pool.length)]!\n const other = pool[Math.floor(rand() * pool.length)]!\n const circumference = 2 * Math.PI * r\n const text = buildRingCopy(\n line,\n other,\n rand() > 0.45,\n circumference,\n fontSize\n )\n\n return {\n d: circlePath(cx, cy, r),\n cx,\n cy,\n strokeWidth: STROKE,\n fontSize,\n text,\n // Keep angular pace lively but readable; outer rings a touch slower.\n duration: 42 + i * 8 + rand() * 10,\n phase: rand(),\n // Alternate direction each ring (inner → outer)\n reverse: i % 2 === 1,\n }\n })\n}\n\nexport type TangleFooterProps = TangleFooterOptions & {\n className?: string\n}\n\n/**\n * Footer of five nested text rings — full-width upper semicircle.\n * The marquee is a continuous rotation of each ring around its center.\n * A full turn is inherently seamless, so it runs on GPU-friendly CSS\n * transforms with no per-frame layout work.\n */\nexport function TangleFooter({\n className,\n lines = DEFAULT_LINES,\n ribbon,\n textColor,\n background,\n height,\n seed = 23,\n label = \"Site footer\",\n}: TangleFooterProps) {\n const reduce = useReducedMotion() ?? false\n const uid = useId().replace(/:/g, \"\")\n const rootRef = useRef(null)\n const [width, setWidth] = useState(0)\n const [paused, setPaused] = useState(false)\n\n useEffect(() => {\n const el = rootRef.current\n if (!el) return\n\n const measure = () => {\n setWidth(el.clientWidth)\n }\n\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n return () => ro.disconnect()\n }, [])\n\n // Pause the animation while off-screen so it never wastes frames.\n useEffect(() => {\n const el = rootRef.current\n if (!el) return\n\n const io = new IntersectionObserver(\n ([entry]) => setPaused(!(entry?.isIntersecting ?? true)),\n { rootMargin: \"64px\", threshold: 0 }\n )\n io.observe(el)\n return () => io.disconnect()\n }, [])\n\n const bandHeight = height ?? (width > 0 ? width / 2 : 0)\n const rings = useMemo(\n () =>\n width > 0 && bandHeight > 0\n ? buildRings(width, bandHeight, lines, seed)\n : [],\n [width, bandHeight, lines, seed]\n )\n\n const spinName = `tangle-spin-${uid}`\n\n return (\n 0 ? bandHeight : undefined,\n aspectRatio: height == null ? \"2 / 1\" : undefined,\n }}\n >\n \n\n {width > 0 && bandHeight > 0 ? (\n \n \n {rings.map((ring, i) => (\n \n ))}\n \n\n {rings.map((ring, i) => {\n const pathId = `${uid}-path-${i}`\n return (\n \n \n \n \n {ring.text}\n \n \n \n )\n })}\n \n ) : null}\n\n

{lines.join(\" \")}

\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_LINES = [\n \"Ship something opinionated — less boilerplate, clearer decisions.\",\n \"Knows what’s going on. Can you check in with them and see what’s next.\",\n \"The new timeline should be ready by Friday, although it’s probably going to slip.\",\n \"Open the docs, grab a component, and make it yours in the codebase.\",\n \"Radiant lines, shader wash, gooey picker — install what you need and move.\",\n]\n\nexport type TangleFooterTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type TangleFooterOptions = {\n /** Phrases that run along the vines. */\n lines?: string[]\n /** Ribbon color. Omit to follow `theme` (ink on light, cream on dark). */\n ribbon?: string\n /** Lettering on the ribbons. Omit to follow `theme`. */\n textColor?: string\n /** How many vines. Default `4`. */\n vines?: number\n /** Marquee speed along the vines, in px per second. Default `36`. */\n speed?: number\n /** Ribbon width in px. Omit to scale with the footer width. */\n thickness?: number\n /** Deterministic tangle. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: TangleFooterTheme\n}\n\nexport type TangleFooterInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const LIGHT_RIBBON = \"#141414\"\nexport const LIGHT_TEXT = \"#F4F0E8\"\nexport const LIGHT_BG = \"#EFEAE2\"\nexport const DARK_RIBBON = \"#E8E4DC\"\nexport const DARK_TEXT = \"#161616\"\nexport const DARK_BG = \"#121210\"\nexport const DEFAULT_VINES = 4\nexport const DEFAULT_SPEED = 36\nexport const DEFAULT_SEED = 23\n\n/** Spacing of the resampled vine, in px. */\nconst STEP = 2\n/** Vines start and end this share of the width past each side. */\nconst OVERHANG = 0.12\n/** Seconds for the vines to grow in. */\nconst GROW_S = 1.8\n\nexport type Vine = {\n /** x, y pairs spaced `STEP` apart along the vine. */\n points: Float32Array\n /** Arc length in px. */\n length: number\n /** Where each piece of the vine starts, in samples, plus the end. */\n cuts: number[]\n /** Stacking order of each piece; higher draws on top. */\n depths: number[]\n /** The copy that repeats along this vine. */\n text: string\n /** Marquee speed multiplier and starting offset (0–1 of the copy). */\n pace: number\n phase: number\n}\n\nexport function mulberry32(seed: number) {\n let t = seed >>> 0\n return () => {\n t += 0x6d2b79f5\n let r = Math.imul(t ^ (t >>> 15), 1 | t)\n r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)\n return ((r ^ (r >>> 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 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/** Smooth wander in −1…1 from three detuned sines. */\nfunction wander(rand: () => number, scale: number) {\n const waves = Array.from({ length: 3 }, (_, i) => ({\n f: ((0.6 + rand() * 0.8) * (i + 1)) / scale,\n p: rand() * Math.PI * 2,\n a: 1 / (i + 1),\n }))\n const total = waves.reduce((sum, w) => sum + w.a, 0)\n return (s: number) =>\n waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total\n}\n\n/** Ribbon width that suits a footer this wide. */\nexport function autoThickness(width: number) {\n return clamp(width * 0.019, 16, 28)\n}\n\n/**\n * Lays out the tangle. Each vine is a wheel rolling left to right with a\n * pen on a spoke longer than the wheel (a prolate trochoid): where the pen\n * outruns the wheel it curls back into a loop, so loops rise off a\n * wandering baseline. Wheel size and spoke length drift along the vine,\n * so some curls are big, some small, some just a wave.\n *\n * Every vine is cut into short pieces with random stacking, so vines pass\n * over and under each other — and themselves — like a real tangle.\n */\nexport function buildVines(\n width: number,\n height: number,\n options: {\n vines?: number\n thickness?: number\n seed?: number\n lines?: string[]\n } = {}\n): Vine[] {\n const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12))\n const w = numberOr(options.thickness, autoThickness(width), 6, 80)\n const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31))\n const pool = options.lines?.filter((line) => line.trim()) ?? []\n const lines = pool.length ? pool : DEFAULT_LINES\n // Pieces short enough that the two strands of any loop land in different\n // pieces, so a loop can cross over itself.\n const piece = w * 4\n\n return Array.from({ length: count }, (_, v) => {\n const rand = mulberry32(seed * 7919 + v * 104729)\n const scale = width * (0.18 + rand() * 0.12)\n const baseline = wander(rand, scale * 1.6)\n const size = wander(rand, scale)\n const curl = wander(rand, scale * 0.8)\n // Spread the vines through the lower band; loops climb from there.\n const floor = height * (0.6 + (v / Math.max(1, count - 1)) * 0.3)\n const wheel0 = Math.max(w * 1.2, height * (0.07 + rand() * 0.04))\n\n const raw: number[] = []\n let phi = rand() * Math.PI * 2\n const start = -width * OVERHANG\n const end = width * (1 + OVERHANG)\n for (let s = start; s <= end; s += 1) {\n const y0 = floor + height * 0.2 * baseline(s)\n const wheel = wheel0 * (1 + 0.45 * size(s))\n // Past 1 the pen outruns the wheel and curls into a loop; near 2 the\n // loop closes round. Below 1 the vine just waves.\n let spoke = wheel * (1.5 + 1.05 * curl(s))\n // Open any loop too tight to read, then keep it under the top edge.\n if (spoke > wheel) spoke = Math.max(spoke, w * 2.3)\n spoke = Math.min(spoke, Math.max(w, (y0 - w * 0.6) / 2))\n phi += 1 / wheel\n raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi)))\n }\n\n // Resample to even spacing so text and pieces are measured in px.\n const points: number[] = [raw[0]!, raw[1]!]\n let carry = 0\n for (let i = 2; i < raw.length; i += 2) {\n const ax = raw[i - 2]!\n const ay = raw[i - 1]!\n const bx = raw[i]!\n const by = raw[i + 1]!\n const seg = Math.hypot(bx - ax, by - ay)\n let at = STEP - carry\n while (at <= seg) {\n const k = at / seg\n points.push(ax + (bx - ax) * k, ay + (by - ay) * k)\n at += STEP\n }\n carry = seg - (at - STEP)\n }\n\n const samples = points.length / 2\n const per = Math.max(2, Math.round(piece / STEP))\n const cuts: number[] = []\n for (let i = 0; i < samples - 1; i += per) cuts.push(i)\n cuts.push(samples - 1)\n const depths = cuts.slice(0, -1).map(() => rand())\n\n const line = lines[Math.floor(rand() * lines.length)]!\n const other = lines[Math.floor(rand() * lines.length)]!\n const text =\n line === other ? `${line} · ` : `${line} · ${other} · `\n\n return {\n points: Float32Array.from(points),\n length: (samples - 1) * STEP,\n cuts,\n depths,\n text,\n pace: 0.8 + rand() * 0.4,\n phase: rand(),\n }\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: TangleFooterTheme = \"auto\"): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\ntype Glyph = { ch: string; adv: number }\ntype Piece = { vine: number; index: number; depth: number }\n\n/**\n * Tangled text vines on a canvas. The vines grow in from the left the first\n * time they're seen, then the copy runs along them. Pauses off-screen and in\n * hidden tabs; holds still under `prefers-reduced-motion`.\n */\nexport function createTangleFooter(\n canvas: HTMLCanvasElement,\n initial: TangleFooterOptions = {}\n): TangleFooterInstance | null {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: TangleFooterOptions = { ...initial }\n const size = { w: 0, h: 0, dpr: 1 }\n let vines: Vine[] = []\n let order: Piece[] = []\n let glyphs: Glyph[][] = []\n let units: number[] = []\n /** Cached gap and ribbon paths per piece; the geometry never moves. */\n let paths: { gap: Path2D; ribbon: Path2D }[][] = []\n let thickness = 20\n let font = \"\"\n let dark = resolveDark(options.theme)\n let grow = 0\n let clock = 0\n let seen = false\n let onScreen = false\n let raf = 0\n let last = 0\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n\n const rebuild = () => {\n const { w, h } = size\n if (w < 2 || h < 2) return\n thickness = numberOr(options.thickness, autoThickness(w), 6, 80)\n vines = buildVines(w, h, { ...options, thickness })\n paths = vines.map((vine) =>\n vine.depths.map((_, k) => ({\n gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!),\n ribbon: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1),\n }))\n )\n order = vines\n .flatMap((vine, v) =>\n vine.depths.map((depth, index) => ({ vine: v, index, depth }))\n )\n .sort((a, b) => a.depth - b.depth)\n const family = getComputedStyle(canvas).fontFamily || \"sans-serif\"\n font = `700 ${Math.round(thickness * 0.5)}px ${family}`\n ctx.font = font\n const widths = new Map()\n glyphs = vines.map((vine) =>\n Array.from(vine.text).map((ch) => {\n let adv = widths.get(ch)\n if (adv === undefined) {\n adv = ctx.measureText(ch).width\n widths.set(ch, adv)\n }\n return { ch, adv }\n })\n )\n units = glyphs.map((list) => list.reduce((sum, g) => sum + g.adv, 0))\n }\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w === size.w && h === size.h && dpr === size.dpr) return\n Object.assign(size, { w, h, dpr })\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n rebuild()\n wake()\n }\n\n /** The stretch of a vine from sample `a` to `b` as a path. */\n function tracePath(vine: Vine, a: number, b: number) {\n const p = vine.points\n const last = p.length / 2 - 1\n const from = clamp(Math.floor(a), 0, last)\n const to = clamp(Math.ceil(b), 0, last)\n const path = new Path2D()\n path.moveTo(p[from * 2]!, p[from * 2 + 1]!)\n for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!)\n return path\n }\n\n function draw() {\n const { w, h, dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.clearRect(0, 0, w, h)\n if (!vines.length) return\n\n const ribbon = options.ribbon?.trim() || (dark ? DARK_RIBBON : LIGHT_RIBBON)\n const ink = options.textColor?.trim() || (dark ? DARK_TEXT : LIGHT_TEXT)\n const halo = Math.max(2.5, thickness * 0.16)\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 400)\n const eased = 1 - (1 - grow) ** 3\n\n // Lay the copy along each vine and hand every glyph to the pieces it\n // touches, so a piece drawn on top never clips a neighbour's letters.\n const placed: [number, number][][][] = vines.map((vine) =>\n vine.depths.map(() => [])\n )\n vines.forEach((vine, v) => {\n const list = glyphs[v]!\n const unit = units[v]!\n if (!list.length || unit <= 0) return\n const reach = vine.length * eased\n const shift = (clock * speed * vine.pace + vine.phase * unit) % unit\n let at = -shift\n let i = 0\n while (at < reach) {\n const adv = list[i]!.adv\n if (at + adv > 0 && list[i]!.ch !== \" \") {\n const mid = at + adv / 2\n if (mid < reach) {\n // Pieces are evenly spaced, so the ones a glyph spans are direct.\n const per = vine.cuts[1]! - vine.cuts[0]!\n const lastPiece = vine.depths.length - 1\n const k0 = Math.min(\n lastPiece,\n Math.floor(Math.max(0, at) / STEP / per)\n )\n const k1 = Math.min(lastPiece, Math.floor((at + adv) / STEP / per))\n for (let k = k0; k <= k1; k++) placed[v]![k]!.push([mid, i])\n }\n }\n at += adv\n i = (i + 1) % list.length\n }\n })\n\n ctx!.lineJoin = \"round\"\n ctx!.lineCap = \"butt\"\n ctx!.font = font\n ctx!.textBaseline = \"middle\"\n ctx!.textAlign = \"center\"\n for (const { vine: v, index } of order) {\n const vine = vines[v]!\n const reach = (vine.length * eased) / STEP\n const a = vine.cuts[index]!\n if (a >= reach) continue\n const b = vine.cuts[index + 1]!\n // Only the growing tip needs a fresh path.\n const whole = b <= reach\n const path = whole\n ? paths[v]![index]!\n : {\n gap: tracePath(vine, a, reach),\n ribbon: tracePath(vine, a - 1, reach),\n }\n\n // Cut a gap around this piece, so whatever it crosses reads as beneath.\n ctx!.globalCompositeOperation = \"destination-out\"\n ctx!.lineWidth = thickness + halo * 2\n ctx!.stroke(path.gap)\n ctx!.globalCompositeOperation = \"source-over\"\n ctx!.strokeStyle = ribbon\n ctx!.lineWidth = thickness\n // The ribbon runs a sample past each end, hiding the seam between pieces.\n ctx!.stroke(path.ribbon)\n\n ctx!.fillStyle = ink\n const p = vine.points\n const lastSample = p.length / 2 - 1\n for (const [mid, gi] of placed[v]![index]!) {\n const s = mid / STEP\n const i0 = clamp(Math.floor(s), 0, lastSample - 1)\n const k = s - i0\n const x0 = p[i0 * 2]!\n const y0 = p[i0 * 2 + 1]!\n const x1 = p[i0 * 2 + 2]!\n const y1 = p[i0 * 2 + 3]!\n const angle = Math.atan2(y1 - y0, x1 - x0)\n const cos = Math.cos(angle)\n const sin = Math.sin(angle)\n ctx!.setTransform(\n dpr * cos,\n dpr * sin,\n -dpr * sin,\n dpr * cos,\n dpr * (x0 + (x1 - x0) * k),\n dpr * (y0 + (y1 - y0) * k)\n )\n ctx!.fillText(glyphs[v]![gi]!.ch, 0, 0)\n }\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n }\n\n const animating = () =>\n onScreen && !reduce && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n function tick(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const moving = animating()\n if (reduce) grow = 1\n else if (seen && moving) grow = Math.min(1, grow + dt / GROW_S)\n if (moving) clock += dt\n draw()\n if (moving) raf = requestAnimationFrame(tick)\n }\n\n const syncTheme = () => {\n const next = resolveDark(options.theme)\n if (next === dark) return\n dark = next\n wake()\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(\n ([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n if (onScreen) seen = true\n wake()\n },\n { rootMargin: \"64px\" }\n )\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => {\n reduce = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\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 // Canvas text can't wait on a web font by itself.\n document.fonts?.ready.then(() => {\n rebuild()\n wake()\n })\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n syncTheme()\n if (\n prev.lines !== options.lines ||\n prev.vines !== options.vines ||\n prev.seed !== options.seed ||\n prev.thickness !== options.thickness\n ) {\n rebuild()\n }\n wake()\n },\n destroy() {\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 TangleFooterProps = TangleFooterOptions & {\n className?: string\n /**\n * Field behind the vines. Omit for theme-aware defaults (warm cream on\n * light, near-black on dark). Pass `\"transparent\"` when the parent\n * already paints the stage.\n */\n background?: string\n /** Footer height in px. Default `320`. */\n height?: number\n /** Accessible label. */\n label?: string\n}\n\n/** Stage color before and behind the canvas; hex literals for Tailwind. */\nconst SURFACE: Record = {\n auto: \"bg-[#EFEAE2] dark:bg-[#121210]\",\n dark: \"bg-[#121210]\",\n light: \"bg-[#EFEAE2]\",\n}\n\n/**\n * A footer of tangled vines — thick ribbons that curl into loops and weave\n * over and under each other, with your copy running along them. The vines\n * grow in the first time the footer is seen.\n */\nexport function TangleFooter({\n className,\n lines = DEFAULT_LINES,\n ribbon,\n textColor,\n background,\n height = 320,\n vines = DEFAULT_VINES,\n speed = DEFAULT_SPEED,\n thickness,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n label = \"Site footer\",\n}: TangleFooterProps) {\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 = createTangleFooter(canvas, {\n lines,\n ribbon,\n textColor,\n vines,\n speed,\n thickness,\n seed,\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 lines,\n ribbon,\n textColor,\n vines,\n speed,\n thickness,\n seed,\n theme,\n })\n }, [lines, ribbon, textColor, vines, speed, thickness, seed, theme])\n\n return (\n \n \n

{lines.join(\" \")}

\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/tangle-footer.tsx" } diff --git a/registry/tangle-footer/registry.json b/registry/tangle-footer/registry.json index 83afe73..92a7f66 100644 --- a/registry/tangle-footer/registry.json +++ b/registry/tangle-footer/registry.json @@ -5,8 +5,7 @@ "name": "tangle-footer", "type": "registry:ui", "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", - "dependencies": ["motion"], + "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", "files": [ { "path": "tangle-footer.tsx", @@ -19,7 +18,7 @@ "name": "tangle-footer-svelte", "type": "registry:ui", "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", + "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", "files": [ { "path": "tangle-footer.svelte", diff --git a/registry/tangle-footer/tangle-footer-demo.tsx b/registry/tangle-footer/tangle-footer-demo.tsx index 2640817..35e4d82 100644 --- a/registry/tangle-footer/tangle-footer-demo.tsx +++ b/registry/tangle-footer/tangle-footer-demo.tsx @@ -11,7 +11,12 @@ import { ComponentPreview } from "@/components/component-preview" import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { cn } from "@/lib/utils" -import { TangleFooter } from "@/registry/tangle-footer/tangle-footer" +import { + DEFAULT_SEED, + DEFAULT_SPEED, + DEFAULT_VINES, + TangleFooter, +} from "@/registry/tangle-footer/tangle-footer" /** Matches TangleFooter theme defaults (uppercase for color inputs). */ const LIGHT = { @@ -66,8 +71,10 @@ export function TangleFooterDemo() { const defaults = useMemo( () => ({ - height: 280, - seed: 23, + height: 320, + vines: DEFAULT_VINES, + speed: DEFAULT_SPEED, + seed: DEFAULT_SEED, ribbon: palette.ribbon, textColor: palette.textColor, stage: palette.stage, @@ -120,6 +127,8 @@ export function TangleFooterDemo() { updateProp("height", v)} /> + updateProp("vines", v)} + /> + updateProp("speed", v)} + /> ) => void + destroy: () => void } -export const RING_COUNT = 5 +export const LIGHT_RIBBON = "#141414" +export const LIGHT_TEXT = "#F4F0E8" +export const LIGHT_BG = "#EFEAE2" +export const DARK_RIBBON = "#E8E4DC" +export const DARK_TEXT = "#161616" +export const DARK_BG = "#121210" +export const DEFAULT_VINES = 4 +export const DEFAULT_SPEED = 36 +export const DEFAULT_SEED = 23 -export const K = 0.5522847498 -export const STROKE = 28 +/** Spacing of the resampled vine, in px. */ +const STEP = 2 +/** Vines start and end this share of the width past each side. */ +const OVERHANG = 0.12 +/** Seconds for the vines to grow in. */ +const GROW_S = 1.8 + +export type Vine = { + /** x, y pairs spaced `STEP` apart along the vine. */ + points: Float32Array + /** Arc length in px. */ + length: number + /** Where each piece of the vine starts, in samples, plus the end. */ + cuts: number[] + /** Stacking order of each piece; higher draws on top. */ + depths: number[] + /** The copy that repeats along this vine. */ + text: string + /** Marquee speed multiplier and starting offset (0–1 of the copy). */ + pace: number + phase: number +} -/** Mulberry32 — deterministic PRNG so rings stay stable across re-renders. */ export function mulberry32(seed: number) { let t = seed >>> 0 return () => { @@ -68,92 +78,433 @@ export function mulberry32(seed: number) { } } -/** Perfect circle as four cubics (container clips to the upper half). */ -export function circlePath(cx: number, cy: number, r: number): string { - const o = r * K - return [ - `M ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`, - `C ${(cx + r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx + o).toFixed(1)} ${(cy + r).toFixed(1)} ${cx.toFixed(1)} ${(cy + r).toFixed(1)}`, - `C ${(cx - o).toFixed(1)} ${(cy + r).toFixed(1)} ${(cx - r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx - r).toFixed(1)} ${cy.toFixed(1)}`, - `C ${(cx - r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx - o).toFixed(1)} ${(cy - r).toFixed(1)} ${cx.toFixed(1)} ${(cy - r).toFixed(1)}`, - `C ${(cx + o).toFixed(1)} ${(cy - r).toFixed(1)} ${(cx + r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`, - ].join(" ") +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) } -/** - * Tile copy so the ring is fully blanketed with no visible gap. - * Rotation makes the loop seamless, so we only need to cover the - * circumference — no exact tile-width matching required. - */ -export function buildRingCopy( - line: string, - other: string, - mix: boolean, - circumference: number, - fontSize: number -): string { - const unit = mix - ? `${line} · ${other} · ` - : `${line} · ${line} · ${other} · ` - // Bold + letter-spacing runs wider than 0.5em — over-estimate so we never underfill. - const unitWidth = Math.max(unit.length * fontSize * 0.72, 1) - const repeats = Math.max(3, Math.ceil(circumference / unitWidth) + 1) - return unit.repeat(repeats) +function numberOr(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, min, max) +} + +/** Smooth wander in −1…1 from three detuned sines. */ +function wander(rand: () => number, scale: number) { + const waves = Array.from({ length: 3 }, (_, i) => ({ + f: ((0.6 + rand() * 0.8) * (i + 1)) / scale, + p: rand() * Math.PI * 2, + a: 1 / (i + 1), + })) + const total = waves.reduce((sum, w) => sum + w.a, 0) + return (s: number) => + waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total +} + +/** Ribbon width that suits a footer this wide. */ +export function autoThickness(width: number) { + return clamp(width * 0.019, 16, 28) } /** - * Five concentric circles nested as an upper semicircle. - * Outer radius fits both width and band height so a short band - * still shows complete arches instead of an equatorial clip. + * Lays out the tangle. Each vine is a wheel rolling left to right with a + * pen on a spoke longer than the wheel (a prolate trochoid): where the pen + * outruns the wheel it curls back into a loop, so loops rise off a + * wandering baseline. Wheel size and spoke length drift along the vine, + * so some curls are big, some small, some just a wave. + * + * Every vine is cut into short pieces with random stacking, so vines pass + * over and under each other — and themselves — like a real tangle. */ -export function buildRings( +export function buildVines( width: number, - bandHeight: number, - lines: string[], - seed: number -): Ring[] { - const rand = mulberry32(seed) - const cx = width / 2 - const cy = bandHeight - const strokePad = STROKE / 2 + 2 - // Full-width nest needs height ≈ width/2; if the band is shorter, - // shrink the nest so the upper semicircle stays fully visible. - const outer = Math.max( - Math.min(width / 2 - strokePad, bandHeight - strokePad), - STROKE * 4 - ) - const radii = Array.from( - { length: RING_COUNT }, - (_, i) => (outer * (i + 1)) / RING_COUNT - ) + height: number, + options: { + vines?: number + thickness?: number + seed?: number + lines?: string[] + } = {} +): Vine[] { + const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12)) + const w = numberOr(options.thickness, autoThickness(width), 6, 80) + const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31)) + const pool = options.lines?.filter((line) => line.trim()) ?? [] + const lines = pool.length ? pool : DEFAULT_LINES + // Pieces short enough that the two strands of any loop land in different + // pieces, so a loop can cross over itself. + const piece = w * 4 - const fontSize = Math.min(24, Math.max(16, width * 0.022)) - const pool = lines.length > 0 ? lines : DEFAULT_LINES - - return radii.map((r, i) => { - const line = pool[Math.floor(rand() * pool.length)]! - const other = pool[Math.floor(rand() * pool.length)]! - const circumference = 2 * Math.PI * r - const text = buildRingCopy( - line, - other, - rand() > 0.45, - circumference, - fontSize - ) + return Array.from({ length: count }, (_, v) => { + const rand = mulberry32(seed * 7919 + v * 104729) + const scale = width * (0.18 + rand() * 0.12) + const baseline = wander(rand, scale * 1.6) + const size = wander(rand, scale) + const curl = wander(rand, scale * 0.8) + // Spread the vines through the lower band; loops climb from there. + const floor = height * (0.6 + (v / Math.max(1, count - 1)) * 0.3) + const wheel0 = Math.max(w * 1.2, height * (0.07 + rand() * 0.04)) + + const raw: number[] = [] + let phi = rand() * Math.PI * 2 + const start = -width * OVERHANG + const end = width * (1 + OVERHANG) + for (let s = start; s <= end; s += 1) { + const y0 = floor + height * 0.2 * baseline(s) + const wheel = wheel0 * (1 + 0.45 * size(s)) + // Past 1 the pen outruns the wheel and curls into a loop; near 2 the + // loop closes round. Below 1 the vine just waves. + let spoke = wheel * (1.5 + 1.05 * curl(s)) + // Open any loop too tight to read, then keep it under the top edge. + if (spoke > wheel) spoke = Math.max(spoke, w * 2.3) + spoke = Math.min(spoke, Math.max(w, (y0 - w * 0.6) / 2)) + phi += 1 / wheel + raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi))) + } + + // Resample to even spacing so text and pieces are measured in px. + const points: number[] = [raw[0]!, raw[1]!] + let carry = 0 + for (let i = 2; i < raw.length; i += 2) { + const ax = raw[i - 2]! + const ay = raw[i - 1]! + const bx = raw[i]! + const by = raw[i + 1]! + const seg = Math.hypot(bx - ax, by - ay) + let at = STEP - carry + while (at <= seg) { + const k = at / seg + points.push(ax + (bx - ax) * k, ay + (by - ay) * k) + at += STEP + } + carry = seg - (at - STEP) + } + + const samples = points.length / 2 + const per = Math.max(2, Math.round(piece / STEP)) + const cuts: number[] = [] + for (let i = 0; i < samples - 1; i += per) cuts.push(i) + cuts.push(samples - 1) + const depths = cuts.slice(0, -1).map(() => rand()) + + const line = lines[Math.floor(rand() * lines.length)]! + const other = lines[Math.floor(rand() * lines.length)]! + const text = + line === other ? `${line} · ` : `${line} · ${other} · ` return { - d: circlePath(cx, cy, r), - cx, - cy, - strokeWidth: STROKE, - fontSize, + points: Float32Array.from(points), + length: (samples - 1) * STEP, + cuts, + depths, text, - // Keep angular pace lively but readable; outer rings a touch slower. - duration: 42 + i * 8 + rand() * 10, + pace: 0.8 + rand() * 0.4, phase: rand(), - // Alternate direction each ring (inner → outer) - reverse: i % 2 === 1, } }) } + +/** 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: TangleFooterTheme = "auto"): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +type Glyph = { ch: string; adv: number } +type Piece = { vine: number; index: number; depth: number } + +/** + * Tangled text vines on a canvas. The vines grow in from the left the first + * time they're seen, then the copy runs along them. Pauses off-screen and in + * hidden tabs; holds still under `prefers-reduced-motion`. + */ +export function createTangleFooter( + canvas: HTMLCanvasElement, + initial: TangleFooterOptions = {} +): TangleFooterInstance | null { + const ctx = canvas.getContext("2d") + if (!ctx) return null + + let options: TangleFooterOptions = { ...initial } + const size = { w: 0, h: 0, dpr: 1 } + let vines: Vine[] = [] + let order: Piece[] = [] + let glyphs: Glyph[][] = [] + let units: number[] = [] + /** Cached gap and ribbon paths per piece; the geometry never moves. */ + let paths: { gap: Path2D; ribbon: Path2D }[][] = [] + let thickness = 20 + let font = "" + let dark = resolveDark(options.theme) + let grow = 0 + let clock = 0 + let seen = false + let onScreen = false + let raf = 0 + let last = 0 + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + let reduce = mqReduce.matches + + const rebuild = () => { + const { w, h } = size + if (w < 2 || h < 2) return + thickness = numberOr(options.thickness, autoThickness(w), 6, 80) + vines = buildVines(w, h, { ...options, thickness }) + paths = vines.map((vine) => + vine.depths.map((_, k) => ({ + gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!), + ribbon: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1), + })) + ) + order = vines + .flatMap((vine, v) => + vine.depths.map((depth, index) => ({ vine: v, index, depth })) + ) + .sort((a, b) => a.depth - b.depth) + const family = getComputedStyle(canvas).fontFamily || "sans-serif" + font = `700 ${Math.round(thickness * 0.5)}px ${family}` + ctx.font = font + const widths = new Map() + glyphs = vines.map((vine) => + Array.from(vine.text).map((ch) => { + let adv = widths.get(ch) + if (adv === undefined) { + adv = ctx.measureText(ch).width + widths.set(ch, adv) + } + return { ch, adv } + }) + ) + units = glyphs.map((list) => list.reduce((sum, g) => sum + g.adv, 0)) + } + + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + 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 + Object.assign(size, { w, h, dpr }) + canvas.width = Math.max(1, Math.floor(w * dpr)) + canvas.height = Math.max(1, Math.floor(h * dpr)) + rebuild() + wake() + } + + /** The stretch of a vine from sample `a` to `b` as a path. */ + function tracePath(vine: Vine, a: number, b: number) { + const p = vine.points + const last = p.length / 2 - 1 + const from = clamp(Math.floor(a), 0, last) + const to = clamp(Math.ceil(b), 0, last) + const path = new Path2D() + path.moveTo(p[from * 2]!, p[from * 2 + 1]!) + for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!) + return path + } + + function draw() { + const { w, h, dpr } = size + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx!.clearRect(0, 0, w, h) + if (!vines.length) return + + const ribbon = options.ribbon?.trim() || (dark ? DARK_RIBBON : LIGHT_RIBBON) + const ink = options.textColor?.trim() || (dark ? DARK_TEXT : LIGHT_TEXT) + const halo = Math.max(2.5, thickness * 0.16) + const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 400) + const eased = 1 - (1 - grow) ** 3 + + // Lay the copy along each vine and hand every glyph to the pieces it + // touches, so a piece drawn on top never clips a neighbour's letters. + const placed: [number, number][][][] = vines.map((vine) => + vine.depths.map(() => []) + ) + vines.forEach((vine, v) => { + const list = glyphs[v]! + const unit = units[v]! + if (!list.length || unit <= 0) return + const reach = vine.length * eased + const shift = (clock * speed * vine.pace + vine.phase * unit) % unit + let at = -shift + let i = 0 + while (at < reach) { + const adv = list[i]!.adv + if (at + adv > 0 && list[i]!.ch !== " ") { + const mid = at + adv / 2 + if (mid < reach) { + // Pieces are evenly spaced, so the ones a glyph spans are direct. + const per = vine.cuts[1]! - vine.cuts[0]! + const lastPiece = vine.depths.length - 1 + const k0 = Math.min( + lastPiece, + Math.floor(Math.max(0, at) / STEP / per) + ) + const k1 = Math.min(lastPiece, Math.floor((at + adv) / STEP / per)) + for (let k = k0; k <= k1; k++) placed[v]![k]!.push([mid, i]) + } + } + at += adv + i = (i + 1) % list.length + } + }) + + ctx!.lineJoin = "round" + ctx!.lineCap = "butt" + ctx!.font = font + ctx!.textBaseline = "middle" + ctx!.textAlign = "center" + for (const { vine: v, index } of order) { + const vine = vines[v]! + const reach = (vine.length * eased) / STEP + const a = vine.cuts[index]! + if (a >= reach) continue + const b = vine.cuts[index + 1]! + // Only the growing tip needs a fresh path. + const whole = b <= reach + const path = whole + ? paths[v]![index]! + : { + gap: tracePath(vine, a, reach), + ribbon: tracePath(vine, a - 1, reach), + } + + // Cut a gap around this piece, so whatever it crosses reads as beneath. + ctx!.globalCompositeOperation = "destination-out" + ctx!.lineWidth = thickness + halo * 2 + ctx!.stroke(path.gap) + ctx!.globalCompositeOperation = "source-over" + ctx!.strokeStyle = ribbon + ctx!.lineWidth = thickness + // The ribbon runs a sample past each end, hiding the seam between pieces. + ctx!.stroke(path.ribbon) + + ctx!.fillStyle = ink + const p = vine.points + const lastSample = p.length / 2 - 1 + for (const [mid, gi] of placed[v]![index]!) { + const s = mid / STEP + const i0 = clamp(Math.floor(s), 0, lastSample - 1) + const k = s - i0 + const x0 = p[i0 * 2]! + const y0 = p[i0 * 2 + 1]! + const x1 = p[i0 * 2 + 2]! + const y1 = p[i0 * 2 + 3]! + const angle = Math.atan2(y1 - y0, x1 - x0) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + ctx!.setTransform( + dpr * cos, + dpr * sin, + -dpr * sin, + dpr * cos, + dpr * (x0 + (x1 - x0) * k), + dpr * (y0 + (y1 - y0) * k) + ) + ctx!.fillText(glyphs[v]![gi]!.ch, 0, 0) + } + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + } + } + + const animating = () => + onScreen && !reduce && document.visibilityState !== "hidden" + + function wake() { + if (raf) return + last = performance.now() + raf = requestAnimationFrame(tick) + } + + function tick(now: number) { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const moving = animating() + if (reduce) grow = 1 + else if (seen && moving) grow = Math.min(1, grow + dt / GROW_S) + if (moving) clock += dt + draw() + if (moving) raf = requestAnimationFrame(tick) + } + + const syncTheme = () => { + const next = resolveDark(options.theme) + if (next === dark) return + dark = next + wake() + } + + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + const io = new IntersectionObserver( + ([entry]) => { + onScreen = entry?.isIntersecting ?? true + if (onScreen) seen = true + wake() + }, + { rootMargin: "64px" } + ) + io.observe(canvas) + const onVisibility = () => wake() + document.addEventListener("visibilitychange", onVisibility) + const onReduce = () => { + reduce = mqReduce.matches + wake() + } + mqReduce.addEventListener("change", onReduce) + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + // Canvas text can't wait on a web font by itself. + document.fonts?.ready.then(() => { + rebuild() + wake() + }) + + resize() + wake() + + return { + setOptions(next) { + const prev = options + options = { ...options, ...next } + syncTheme() + if ( + prev.lines !== options.lines || + prev.vines !== options.vines || + prev.seed !== options.seed || + prev.thickness !== options.thickness + ) { + rebuild() + } + wake() + }, + destroy() { + cancelAnimationFrame(raf) + raf = 0 + ro.disconnect() + io.disconnect() + mo.disconnect() + document.removeEventListener("visibilitychange", onVisibility) + mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) + }, + } +} diff --git a/registry/tangle-footer/tangle-footer.svelte b/registry/tangle-footer/tangle-footer.svelte index 9e8e2c7..f0762ae 100644 --- a/registry/tangle-footer/tangle-footer.svelte +++ b/registry/tangle-footer/tangle-footer.svelte @@ -2,11 +2,16 @@
0 ? `${bandHeight}px` : undefined} - style:aspect-ratio={height == null ? "2 / 1" : undefined} + style:height="{height}px" > - {#if showSvg} - - - {#each rings as ring, i} - - {/each} - - - {#each rings as ring, i} - {@const pathId = `${uid}-path-${i}`} - - - - {ring.text} - - - {/each} - - {/if} - +

{lines.join(" ")}

- - diff --git a/registry/tangle-footer/tangle-footer.tsx b/registry/tangle-footer/tangle-footer.tsx index b30c166..f39053e 100644 --- a/registry/tangle-footer/tangle-footer.tsx +++ b/registry/tangle-footer/tangle-footer.tsx @@ -1,25 +1,63 @@ "use client" -import { useEffect, useId, useMemo, useRef, useState } from "react" -import { motion, useReducedMotion } from "motion/react" +import { useEffect, useRef } from "react" import { cn } from "@/lib/utils" import { - buildRings, + createTangleFooter, DEFAULT_LINES, + DEFAULT_SEED, + DEFAULT_SPEED, + DEFAULT_VINES, + type TangleFooterInstance, type TangleFooterOptions, + type TangleFooterTheme, +} from "./tangle-footer-vanilla" + +export { + DARK_BG, + DARK_RIBBON, + DARK_TEXT, + DEFAULT_LINES, + DEFAULT_SEED, + DEFAULT_SPEED, + DEFAULT_VINES, + LIGHT_BG, + LIGHT_RIBBON, + LIGHT_TEXT, +} from "./tangle-footer-vanilla" +export type { + TangleFooterInstance, + TangleFooterOptions, + TangleFooterTheme, } from "./tangle-footer-vanilla" export type TangleFooterProps = TangleFooterOptions & { className?: string + /** + * Field behind the vines. Omit for theme-aware defaults (warm cream on + * light, near-black on dark). Pass `"transparent"` when the parent + * already paints the stage. + */ + background?: string + /** Footer height in px. Default `320`. */ + height?: number + /** Accessible label. */ + label?: string +} + +/** Stage color before and behind the canvas; hex literals for Tailwind. */ +const SURFACE: Record = { + auto: "bg-[#EFEAE2] dark:bg-[#121210]", + dark: "bg-[#121210]", + light: "bg-[#EFEAE2]", } /** - * Footer of five nested text rings — full-width upper semicircle. - * The marquee is a continuous rotation of each ring around its center. - * A full turn is inherently seamless, so it runs on GPU-friendly CSS - * transforms with no per-frame layout work. + * A footer of tangled vines — thick ribbons that curl into loops and weave + * over and under each other, with your copy running along them. The vines + * grow in the first time the footer is seen. */ export function TangleFooter({ className, @@ -27,150 +65,67 @@ export function TangleFooter({ ribbon, textColor, background, - height, - seed = 23, + height = 320, + vines = DEFAULT_VINES, + speed = DEFAULT_SPEED, + thickness, + seed = DEFAULT_SEED, + theme = "auto", label = "Site footer", }: TangleFooterProps) { - const reduce = useReducedMotion() ?? false - const uid = useId().replace(/:/g, "") - const rootRef = useRef(null) - const [width, setWidth] = useState(0) - const [paused, setPaused] = useState(false) + const canvasRef = useRef(null) + const instanceRef = useRef(null) useEffect(() => { - const el = rootRef.current - if (!el) return - - const measure = () => { - setWidth(el.clientWidth) + const canvas = canvasRef.current + if (!canvas) return + instanceRef.current = createTangleFooter(canvas, { + lines, + ribbon, + textColor, + vines, + speed, + thickness, + seed, + theme, + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null } - - measure() - const ro = new ResizeObserver(measure) - ro.observe(el) - return () => ro.disconnect() + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Pause the animation while off-screen so it never wastes frames. useEffect(() => { - const el = rootRef.current - if (!el) return - - const io = new IntersectionObserver( - ([entry]) => setPaused(!(entry?.isIntersecting ?? true)), - { rootMargin: "64px", threshold: 0 } - ) - io.observe(el) - return () => io.disconnect() - }, []) - - const bandHeight = height ?? (width > 0 ? width / 2 : 0) - const rings = useMemo( - () => - width > 0 && bandHeight > 0 - ? buildRings(width, bandHeight, lines, seed) - : [], - [width, bandHeight, lines, seed] - ) - - const spinName = `tangle-spin-${uid}` + instanceRef.current?.setOptions({ + lines, + ribbon, + textColor, + vines, + speed, + thickness, + seed, + theme, + }) + }, [lines, ribbon, textColor, vines, speed, thickness, seed, theme]) return (
0 ? bandHeight : undefined, - aspectRatio: height == null ? "2 / 1" : undefined, - }} + style={{ background, height }} > - - - {width > 0 && bandHeight > 0 ? ( - - - {rings.map((ring, i) => ( - - ))} - - - {rings.map((ring, i) => { - const pathId = `${uid}-path-${i}` - return ( - - - - - {ring.text} - - - - ) - })} - - ) : null} - +

{lines.join(" ")}

) From 63aa454dbd350292c826c0c3051bcb438260db93 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:41:47 +0530 Subject: [PATCH 17/49] Strip Tangle Footer down to clean tangled lines. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No text and no stage palette: thin pen-line vines in the text color on a transparent footer, so it follows light and dark on any page. Loops snap between round curls and soft waves instead of teardrops and cusps, crossings keep a hairline over-under gap, and the vines grow in once then hold still — no frames after that. Props are height, vines, thickness, color and seed. --- content/docs/components/tangle-footer.mdx | 58 +-- public/r/registry.json | 4 +- public/r/tangle-footer-svelte.json | 4 +- public/r/tangle-footer.json | 4 +- registry/tangle-footer/registry.json | 4 +- registry/tangle-footer/tangle-footer-demo.tsx | 150 ++------ .../tangle-footer/tangle-footer-vanilla.ts | 337 +++++------------- registry/tangle-footer/tangle-footer.svelte | 57 +-- registry/tangle-footer/tangle-footer.tsx | 75 +--- 9 files changed, 144 insertions(+), 549 deletions(-) diff --git a/content/docs/components/tangle-footer.mdx b/content/docs/components/tangle-footer.mdx index 1805579..31b01db 100644 --- a/content/docs/components/tangle-footer.mdx +++ b/content/docs/components/tangle-footer.mdx @@ -1,11 +1,11 @@ --- title: Tangle Footer -description: Tangled text vines — ribbons curl into loops and weave over and under each other while your copy runs along them. +description: Tangled vines for the bottom of a page — clean pen lines that curl into loops and cross over and under each other. --- import { TangleFooterDemo } from "@/registry/tangle-footer/tangle-footer-demo" -Vines run the width of the footer, curling into loops of every size off a wandering baseline. Each one is cut into short pieces with a random stacking order, so the vines pass over and under each other — and themselves — with a thin gap where they cross. Your phrases run along the ribbons as a slow marquee, through every loop. The vines grow in the first time the footer is seen. Change **Seed** for a new tangle. Press `d` to toggle the theme. +A few vines run the width of the footer, curling into round loops off a wandering baseline and relaxing into waves between them. Where lines cross, the one on top clears a hairline gap around itself, so every crossing reads as over or under — including loops crossing themselves. It's transparent and drawn in the text color, so it sits on any page and follows light and dark. The vines grow in once, the first time the footer is seen, then hold still. Change **Seed** for a new tangle. ## Preview @@ -21,27 +21,15 @@ Or install straight from the public GitHub source registry: ## Usage -Drop it at the bottom of the page. It fills the width it's given at `height` px tall; pass phrases via `lines` and each vine picks one or two. +Put it at the bottom of the page. It fills the width it's given, `height` px tall, in the current text color — set `className="text-muted-foreground"` (or `color`) to change it. ```tsx -"use client" - import { TangleFooter } from "@/components/ui/tangle-footer" export function SiteFooter() { - return ( -
- -
- ) + return } ```
@@ -51,35 +39,21 @@ export function SiteFooter() { import TangleFooter from "$lib/components/ui/tangle-footer.svelte" -
- -
+ ```
## Props -| Prop | Type | Default | -| ------------ | ----------------------------- | -------------------------------------- | -| `lines` | `string[]` | built-in phrases | -| `ribbon` | `string` | theme (`#141414` / `#E8E4DC`) | -| `textColor` | `string` | theme (`#F4F0E8` / `#161616`) | -| `background` | `string` | theme (`#EFEAE2` / `#121210`) | -| `height` | `number` | `320` — px | -| `vines` | `number` | `4` — `1`–`12` | -| `speed` | `number` | `36` — px per second along the vines | -| `thickness` | `number` | scales with width — ribbon width in px | -| `seed` | `number` | `23` — a new seed is a new tangle | -| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | -| `label` | `string` | `Site footer` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | - -`prefers-reduced-motion: reduce` shows the grown tangle with the copy standing still. The animation also pauses while the footer is off-screen or the tab is hidden. +| Prop | Type | Default | +| ----------- | -------- | --------------------------------------------- | +| `height` | `number` | `320` — px | +| `vines` | `number` | `3` — `1`–`12` | +| `thickness` | `number` | scales with width — line width in px | +| `color` | `string` | `currentColor` | +| `seed` | `number` | `23` — a new seed grows a new tangle | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | + +`prefers-reduced-motion: reduce` skips the grow-in and shows the finished tangle. diff --git a/public/r/registry.json b/public/r/registry.json index ac43b08..1d8b40c 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -351,7 +351,7 @@ { "name": "tangle-footer", "title": "Tangle Footer", - "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", + "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", "files": [ { "path": "registry/tangle-footer/tangle-footer.tsx", @@ -364,7 +364,7 @@ { "name": "tangle-footer-svelte", "title": "Tangle Footer", - "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", + "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", "dependencies": [], "files": [ { diff --git a/public/r/tangle-footer-svelte.json b/public/r/tangle-footer-svelte.json index 95df94e..9cfbb9a 100644 --- a/public/r/tangle-footer-svelte.json +++ b/public/r/tangle-footer-svelte.json @@ -2,12 +2,12 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tangle-footer-svelte", "title": "Tangle Footer", - "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", + "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", "dependencies": [], "files": [ { "path": "registry/tangle-footer/tangle-footer.svelte", - "content": "\n\n\n\n\n \n

{lines.join(\" \")}

\n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/tangle-footer.svelte" } diff --git a/public/r/tangle-footer.json b/public/r/tangle-footer.json index 5c38bd4..70d6be0 100644 --- a/public/r/tangle-footer.json +++ b/public/r/tangle-footer.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tangle-footer", "title": "Tangle Footer", - "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", + "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", "files": [ { "path": "registry/tangle-footer/tangle-footer.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_LINES = [\n \"Ship something opinionated — less boilerplate, clearer decisions.\",\n \"Knows what’s going on. Can you check in with them and see what’s next.\",\n \"The new timeline should be ready by Friday, although it’s probably going to slip.\",\n \"Open the docs, grab a component, and make it yours in the codebase.\",\n \"Radiant lines, shader wash, gooey picker — install what you need and move.\",\n]\n\nexport type TangleFooterTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type TangleFooterOptions = {\n /** Phrases that run along the vines. */\n lines?: string[]\n /** Ribbon color. Omit to follow `theme` (ink on light, cream on dark). */\n ribbon?: string\n /** Lettering on the ribbons. Omit to follow `theme`. */\n textColor?: string\n /** How many vines. Default `4`. */\n vines?: number\n /** Marquee speed along the vines, in px per second. Default `36`. */\n speed?: number\n /** Ribbon width in px. Omit to scale with the footer width. */\n thickness?: number\n /** Deterministic tangle. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: TangleFooterTheme\n}\n\nexport type TangleFooterInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const LIGHT_RIBBON = \"#141414\"\nexport const LIGHT_TEXT = \"#F4F0E8\"\nexport const LIGHT_BG = \"#EFEAE2\"\nexport const DARK_RIBBON = \"#E8E4DC\"\nexport const DARK_TEXT = \"#161616\"\nexport const DARK_BG = \"#121210\"\nexport const DEFAULT_VINES = 4\nexport const DEFAULT_SPEED = 36\nexport const DEFAULT_SEED = 23\n\n/** Spacing of the resampled vine, in px. */\nconst STEP = 2\n/** Vines start and end this share of the width past each side. */\nconst OVERHANG = 0.12\n/** Seconds for the vines to grow in. */\nconst GROW_S = 1.8\n\nexport type Vine = {\n /** x, y pairs spaced `STEP` apart along the vine. */\n points: Float32Array\n /** Arc length in px. */\n length: number\n /** Where each piece of the vine starts, in samples, plus the end. */\n cuts: number[]\n /** Stacking order of each piece; higher draws on top. */\n depths: number[]\n /** The copy that repeats along this vine. */\n text: string\n /** Marquee speed multiplier and starting offset (0–1 of the copy). */\n pace: number\n phase: number\n}\n\nexport function mulberry32(seed: number) {\n let t = seed >>> 0\n return () => {\n t += 0x6d2b79f5\n let r = Math.imul(t ^ (t >>> 15), 1 | t)\n r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)\n return ((r ^ (r >>> 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 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/** Smooth wander in −1…1 from three detuned sines. */\nfunction wander(rand: () => number, scale: number) {\n const waves = Array.from({ length: 3 }, (_, i) => ({\n f: ((0.6 + rand() * 0.8) * (i + 1)) / scale,\n p: rand() * Math.PI * 2,\n a: 1 / (i + 1),\n }))\n const total = waves.reduce((sum, w) => sum + w.a, 0)\n return (s: number) =>\n waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total\n}\n\n/** Ribbon width that suits a footer this wide. */\nexport function autoThickness(width: number) {\n return clamp(width * 0.019, 16, 28)\n}\n\n/**\n * Lays out the tangle. Each vine is a wheel rolling left to right with a\n * pen on a spoke longer than the wheel (a prolate trochoid): where the pen\n * outruns the wheel it curls back into a loop, so loops rise off a\n * wandering baseline. Wheel size and spoke length drift along the vine,\n * so some curls are big, some small, some just a wave.\n *\n * Every vine is cut into short pieces with random stacking, so vines pass\n * over and under each other — and themselves — like a real tangle.\n */\nexport function buildVines(\n width: number,\n height: number,\n options: {\n vines?: number\n thickness?: number\n seed?: number\n lines?: string[]\n } = {}\n): Vine[] {\n const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12))\n const w = numberOr(options.thickness, autoThickness(width), 6, 80)\n const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31))\n const pool = options.lines?.filter((line) => line.trim()) ?? []\n const lines = pool.length ? pool : DEFAULT_LINES\n // Pieces short enough that the two strands of any loop land in different\n // pieces, so a loop can cross over itself.\n const piece = w * 4\n\n return Array.from({ length: count }, (_, v) => {\n const rand = mulberry32(seed * 7919 + v * 104729)\n const scale = width * (0.18 + rand() * 0.12)\n const baseline = wander(rand, scale * 1.6)\n const size = wander(rand, scale)\n const curl = wander(rand, scale * 0.8)\n // Spread the vines through the lower band; loops climb from there.\n const floor = height * (0.6 + (v / Math.max(1, count - 1)) * 0.3)\n const wheel0 = Math.max(w * 1.2, height * (0.07 + rand() * 0.04))\n\n const raw: number[] = []\n let phi = rand() * Math.PI * 2\n const start = -width * OVERHANG\n const end = width * (1 + OVERHANG)\n for (let s = start; s <= end; s += 1) {\n const y0 = floor + height * 0.2 * baseline(s)\n const wheel = wheel0 * (1 + 0.45 * size(s))\n // Past 1 the pen outruns the wheel and curls into a loop; near 2 the\n // loop closes round. Below 1 the vine just waves.\n let spoke = wheel * (1.5 + 1.05 * curl(s))\n // Open any loop too tight to read, then keep it under the top edge.\n if (spoke > wheel) spoke = Math.max(spoke, w * 2.3)\n spoke = Math.min(spoke, Math.max(w, (y0 - w * 0.6) / 2))\n phi += 1 / wheel\n raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi)))\n }\n\n // Resample to even spacing so text and pieces are measured in px.\n const points: number[] = [raw[0]!, raw[1]!]\n let carry = 0\n for (let i = 2; i < raw.length; i += 2) {\n const ax = raw[i - 2]!\n const ay = raw[i - 1]!\n const bx = raw[i]!\n const by = raw[i + 1]!\n const seg = Math.hypot(bx - ax, by - ay)\n let at = STEP - carry\n while (at <= seg) {\n const k = at / seg\n points.push(ax + (bx - ax) * k, ay + (by - ay) * k)\n at += STEP\n }\n carry = seg - (at - STEP)\n }\n\n const samples = points.length / 2\n const per = Math.max(2, Math.round(piece / STEP))\n const cuts: number[] = []\n for (let i = 0; i < samples - 1; i += per) cuts.push(i)\n cuts.push(samples - 1)\n const depths = cuts.slice(0, -1).map(() => rand())\n\n const line = lines[Math.floor(rand() * lines.length)]!\n const other = lines[Math.floor(rand() * lines.length)]!\n const text =\n line === other ? `${line} · ` : `${line} · ${other} · `\n\n return {\n points: Float32Array.from(points),\n length: (samples - 1) * STEP,\n cuts,\n depths,\n text,\n pace: 0.8 + rand() * 0.4,\n phase: rand(),\n }\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: TangleFooterTheme = \"auto\"): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\ntype Glyph = { ch: string; adv: number }\ntype Piece = { vine: number; index: number; depth: number }\n\n/**\n * Tangled text vines on a canvas. The vines grow in from the left the first\n * time they're seen, then the copy runs along them. Pauses off-screen and in\n * hidden tabs; holds still under `prefers-reduced-motion`.\n */\nexport function createTangleFooter(\n canvas: HTMLCanvasElement,\n initial: TangleFooterOptions = {}\n): TangleFooterInstance | null {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: TangleFooterOptions = { ...initial }\n const size = { w: 0, h: 0, dpr: 1 }\n let vines: Vine[] = []\n let order: Piece[] = []\n let glyphs: Glyph[][] = []\n let units: number[] = []\n /** Cached gap and ribbon paths per piece; the geometry never moves. */\n let paths: { gap: Path2D; ribbon: Path2D }[][] = []\n let thickness = 20\n let font = \"\"\n let dark = resolveDark(options.theme)\n let grow = 0\n let clock = 0\n let seen = false\n let onScreen = false\n let raf = 0\n let last = 0\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n\n const rebuild = () => {\n const { w, h } = size\n if (w < 2 || h < 2) return\n thickness = numberOr(options.thickness, autoThickness(w), 6, 80)\n vines = buildVines(w, h, { ...options, thickness })\n paths = vines.map((vine) =>\n vine.depths.map((_, k) => ({\n gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!),\n ribbon: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1),\n }))\n )\n order = vines\n .flatMap((vine, v) =>\n vine.depths.map((depth, index) => ({ vine: v, index, depth }))\n )\n .sort((a, b) => a.depth - b.depth)\n const family = getComputedStyle(canvas).fontFamily || \"sans-serif\"\n font = `700 ${Math.round(thickness * 0.5)}px ${family}`\n ctx.font = font\n const widths = new Map()\n glyphs = vines.map((vine) =>\n Array.from(vine.text).map((ch) => {\n let adv = widths.get(ch)\n if (adv === undefined) {\n adv = ctx.measureText(ch).width\n widths.set(ch, adv)\n }\n return { ch, adv }\n })\n )\n units = glyphs.map((list) => list.reduce((sum, g) => sum + g.adv, 0))\n }\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w === size.w && h === size.h && dpr === size.dpr) return\n Object.assign(size, { w, h, dpr })\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n rebuild()\n wake()\n }\n\n /** The stretch of a vine from sample `a` to `b` as a path. */\n function tracePath(vine: Vine, a: number, b: number) {\n const p = vine.points\n const last = p.length / 2 - 1\n const from = clamp(Math.floor(a), 0, last)\n const to = clamp(Math.ceil(b), 0, last)\n const path = new Path2D()\n path.moveTo(p[from * 2]!, p[from * 2 + 1]!)\n for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!)\n return path\n }\n\n function draw() {\n const { w, h, dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.clearRect(0, 0, w, h)\n if (!vines.length) return\n\n const ribbon = options.ribbon?.trim() || (dark ? DARK_RIBBON : LIGHT_RIBBON)\n const ink = options.textColor?.trim() || (dark ? DARK_TEXT : LIGHT_TEXT)\n const halo = Math.max(2.5, thickness * 0.16)\n const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 400)\n const eased = 1 - (1 - grow) ** 3\n\n // Lay the copy along each vine and hand every glyph to the pieces it\n // touches, so a piece drawn on top never clips a neighbour's letters.\n const placed: [number, number][][][] = vines.map((vine) =>\n vine.depths.map(() => [])\n )\n vines.forEach((vine, v) => {\n const list = glyphs[v]!\n const unit = units[v]!\n if (!list.length || unit <= 0) return\n const reach = vine.length * eased\n const shift = (clock * speed * vine.pace + vine.phase * unit) % unit\n let at = -shift\n let i = 0\n while (at < reach) {\n const adv = list[i]!.adv\n if (at + adv > 0 && list[i]!.ch !== \" \") {\n const mid = at + adv / 2\n if (mid < reach) {\n // Pieces are evenly spaced, so the ones a glyph spans are direct.\n const per = vine.cuts[1]! - vine.cuts[0]!\n const lastPiece = vine.depths.length - 1\n const k0 = Math.min(\n lastPiece,\n Math.floor(Math.max(0, at) / STEP / per)\n )\n const k1 = Math.min(lastPiece, Math.floor((at + adv) / STEP / per))\n for (let k = k0; k <= k1; k++) placed[v]![k]!.push([mid, i])\n }\n }\n at += adv\n i = (i + 1) % list.length\n }\n })\n\n ctx!.lineJoin = \"round\"\n ctx!.lineCap = \"butt\"\n ctx!.font = font\n ctx!.textBaseline = \"middle\"\n ctx!.textAlign = \"center\"\n for (const { vine: v, index } of order) {\n const vine = vines[v]!\n const reach = (vine.length * eased) / STEP\n const a = vine.cuts[index]!\n if (a >= reach) continue\n const b = vine.cuts[index + 1]!\n // Only the growing tip needs a fresh path.\n const whole = b <= reach\n const path = whole\n ? paths[v]![index]!\n : {\n gap: tracePath(vine, a, reach),\n ribbon: tracePath(vine, a - 1, reach),\n }\n\n // Cut a gap around this piece, so whatever it crosses reads as beneath.\n ctx!.globalCompositeOperation = \"destination-out\"\n ctx!.lineWidth = thickness + halo * 2\n ctx!.stroke(path.gap)\n ctx!.globalCompositeOperation = \"source-over\"\n ctx!.strokeStyle = ribbon\n ctx!.lineWidth = thickness\n // The ribbon runs a sample past each end, hiding the seam between pieces.\n ctx!.stroke(path.ribbon)\n\n ctx!.fillStyle = ink\n const p = vine.points\n const lastSample = p.length / 2 - 1\n for (const [mid, gi] of placed[v]![index]!) {\n const s = mid / STEP\n const i0 = clamp(Math.floor(s), 0, lastSample - 1)\n const k = s - i0\n const x0 = p[i0 * 2]!\n const y0 = p[i0 * 2 + 1]!\n const x1 = p[i0 * 2 + 2]!\n const y1 = p[i0 * 2 + 3]!\n const angle = Math.atan2(y1 - y0, x1 - x0)\n const cos = Math.cos(angle)\n const sin = Math.sin(angle)\n ctx!.setTransform(\n dpr * cos,\n dpr * sin,\n -dpr * sin,\n dpr * cos,\n dpr * (x0 + (x1 - x0) * k),\n dpr * (y0 + (y1 - y0) * k)\n )\n ctx!.fillText(glyphs[v]![gi]!.ch, 0, 0)\n }\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n }\n\n const animating = () =>\n onScreen && !reduce && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n function tick(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const moving = animating()\n if (reduce) grow = 1\n else if (seen && moving) grow = Math.min(1, grow + dt / GROW_S)\n if (moving) clock += dt\n draw()\n if (moving) raf = requestAnimationFrame(tick)\n }\n\n const syncTheme = () => {\n const next = resolveDark(options.theme)\n if (next === dark) return\n dark = next\n wake()\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(\n ([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n if (onScreen) seen = true\n wake()\n },\n { rootMargin: \"64px\" }\n )\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => {\n reduce = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\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 // Canvas text can't wait on a web font by itself.\n document.fonts?.ready.then(() => {\n rebuild()\n wake()\n })\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n syncTheme()\n if (\n prev.lines !== options.lines ||\n prev.vines !== options.vines ||\n prev.seed !== options.seed ||\n prev.thickness !== options.thickness\n ) {\n rebuild()\n }\n wake()\n },\n destroy() {\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 TangleFooterProps = TangleFooterOptions & {\n className?: string\n /**\n * Field behind the vines. Omit for theme-aware defaults (warm cream on\n * light, near-black on dark). Pass `\"transparent\"` when the parent\n * already paints the stage.\n */\n background?: string\n /** Footer height in px. Default `320`. */\n height?: number\n /** Accessible label. */\n label?: string\n}\n\n/** Stage color before and behind the canvas; hex literals for Tailwind. */\nconst SURFACE: Record = {\n auto: \"bg-[#EFEAE2] dark:bg-[#121210]\",\n dark: \"bg-[#121210]\",\n light: \"bg-[#EFEAE2]\",\n}\n\n/**\n * A footer of tangled vines — thick ribbons that curl into loops and weave\n * over and under each other, with your copy running along them. The vines\n * grow in the first time the footer is seen.\n */\nexport function TangleFooter({\n className,\n lines = DEFAULT_LINES,\n ribbon,\n textColor,\n background,\n height = 320,\n vines = DEFAULT_VINES,\n speed = DEFAULT_SPEED,\n thickness,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n label = \"Site footer\",\n}: TangleFooterProps) {\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 = createTangleFooter(canvas, {\n lines,\n ribbon,\n textColor,\n vines,\n speed,\n thickness,\n seed,\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 lines,\n ribbon,\n textColor,\n vines,\n speed,\n thickness,\n seed,\n theme,\n })\n }, [lines, ribbon, textColor, vines, speed, thickness, seed, theme])\n\n return (\n \n \n

{lines.join(\" \")}

\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type TangleFooterOptions = {\n /** Line color, any CSS color. Omit to use the text color (`currentColor`). */\n color?: string\n /** How many vines. Default `3`. */\n vines?: number\n /** Line width in px. Omit to scale with the footer width. */\n thickness?: number\n /** Deterministic tangle. Default `23`. */\n seed?: number\n}\n\nexport type TangleFooterInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_VINES = 3\nexport const DEFAULT_SEED = 23\n\n/** Spacing of the resampled vine, in px. */\nconst STEP = 2\n/** Vines start and end this share of the width past each side. */\nconst OVERHANG = 0.12\n/** Seconds for the vines to grow in. */\nconst GROW_S = 1.8\n\nexport type Vine = {\n /** x, y pairs spaced `STEP` apart along the vine. */\n points: Float32Array\n /** Where each piece of the vine starts, in samples, plus the end. */\n cuts: number[]\n /** Stacking order of each piece; higher draws on top. */\n depths: number[]\n}\n\nexport function mulberry32(seed: number) {\n let t = seed >>> 0\n return () => {\n t += 0x6d2b79f5\n let r = Math.imul(t ^ (t >>> 15), 1 | t)\n r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)\n return ((r ^ (r >>> 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 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/** Smooth wander in −1…1 from three detuned sines. */\nfunction wander(rand: () => number, scale: number) {\n const waves = Array.from({ length: 3 }, (_, i) => ({\n f: ((0.6 + rand() * 0.8) * (i + 1)) / scale,\n p: rand() * Math.PI * 2,\n a: 1 / (i + 1),\n }))\n const total = waves.reduce((sum, w) => sum + w.a, 0)\n return (s: number) =>\n waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total\n}\n\n/** A pen-line width that suits a footer this wide. */\nexport function autoThickness(width: number) {\n return clamp(width * 0.0021, 1.75, 3.5)\n}\n\n/**\n * Lays out the tangle. Each vine is a wheel rolling left to right with a\n * pen on a spoke longer than the wheel (a prolate trochoid): where the pen\n * outruns the wheel it curls back into a loop, so loops rise off a\n * wandering baseline. Wheel size and spoke length drift along the vine,\n * so some curls are big, some small, some just a wave.\n *\n * Every vine is cut into short pieces with random stacking, so vines pass\n * over and under each other — and themselves — like a real tangle.\n */\nexport function buildVines(\n width: number,\n height: number,\n options: Pick = {}\n): Vine[] {\n const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12))\n const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31))\n // Smallest loop the wheel sizes produce (spoke ≈ 2 wheels at the low end).\n const minLoop = height * 0.14\n // Pieces shorter than half the smallest loop, so the two strands of any\n // loop land in different pieces and the loop can cross over itself.\n const piece = minLoop * 1.5\n\n return Array.from({ length: count }, (_, v) => {\n const rand = mulberry32(seed * 7919 + v * 104729)\n const scale = width * (0.18 + rand() * 0.12)\n const baseline = wander(rand, scale * 1.6)\n const size = wander(rand, scale)\n const curl = wander(rand, scale * 0.8)\n // Spread the vines through the lower band; loops climb from there.\n const floor = height * (0.62 + (v / Math.max(1, count - 1)) * 0.28)\n const wheel0 = height * (0.1 + rand() * 0.05)\n\n const raw: number[] = []\n let phi = rand() * Math.PI * 2\n const start = -width * OVERHANG\n const end = width * (1 + OVERHANG)\n for (let s = start; s <= end; s += 1) {\n const y0 = floor + height * 0.18 * baseline(s)\n const wheel = wheel0 * (1 + 0.45 * size(s))\n // Past 1 the pen outruns the wheel and curls into a loop; near 2 the\n // loop closes round. Below 1 the vine just waves. The ratio snaps\n // quickly between the two, since loops just past 1 read as thin\n // teardrops and waves just under 1 as sharp cusps.\n const c = curl(s)\n const k = clamp((c + 0.1) / 0.25, 0, 1)\n let spoke = wheel * (0.7 + 1.35 * k * k * (3 - 2 * k) + 0.25 * c)\n // Keep every loop under the top edge.\n spoke = Math.min(spoke, Math.max(minLoop * 0.5, (y0 - height * 0.06) / 2))\n phi += 1 / wheel\n raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi)))\n }\n\n // Resample to even spacing so pieces are measured in px.\n const points: number[] = [raw[0]!, raw[1]!]\n let carry = 0\n for (let i = 2; i < raw.length; i += 2) {\n const ax = raw[i - 2]!\n const ay = raw[i - 1]!\n const bx = raw[i]!\n const by = raw[i + 1]!\n const seg = Math.hypot(bx - ax, by - ay)\n let at = STEP - carry\n while (at <= seg) {\n const k = at / seg\n points.push(ax + (bx - ax) * k, ay + (by - ay) * k)\n at += STEP\n }\n carry = seg - (at - STEP)\n }\n\n const samples = points.length / 2\n const per = Math.max(2, Math.round(piece / STEP))\n const cuts: number[] = []\n for (let i = 0; i < samples - 1; i += per) cuts.push(i)\n cuts.push(samples - 1)\n\n return {\n points: Float32Array.from(points),\n cuts,\n depths: cuts.slice(0, -1).map(() => rand()),\n }\n })\n}\n\ntype Piece = { vine: number; index: number; depth: number }\n\n/**\n * Tangled pen-line vines on a transparent canvas, in the text color by\n * default. They grow in from the left the first time they're seen, then\n * hold still — nothing runs after that. Redraws on resize and theme change.\n */\nexport function createTangleFooter(\n canvas: HTMLCanvasElement,\n initial: TangleFooterOptions = {}\n): TangleFooterInstance | null {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: TangleFooterOptions = { ...initial }\n const size = { w: 0, h: 0, dpr: 1 }\n let vines: Vine[] = []\n let order: Piece[] = []\n /** Cached gap and line paths per piece; the geometry never moves. */\n let paths: { gap: Path2D; line: Path2D }[][] = []\n let thickness = 2\n let grow = 0\n let seen = false\n let raf = 0\n let last = 0\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n\n /** The stretch of a vine from sample `a` to `b` as a path. */\n function tracePath(vine: Vine, a: number, b: number) {\n const p = vine.points\n const end = p.length / 2 - 1\n const from = clamp(Math.floor(a), 0, end)\n const to = clamp(Math.ceil(b), 0, end)\n const path = new Path2D()\n path.moveTo(p[from * 2]!, p[from * 2 + 1]!)\n for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!)\n return path\n }\n\n const rebuild = () => {\n const { w, h } = size\n if (w < 2 || h < 2) return\n thickness = numberOr(options.thickness, autoThickness(w), 0.5, 24)\n vines = buildVines(w, h, options)\n paths = vines.map((vine) =>\n vine.depths.map((_, k) => ({\n gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!),\n // A sample past each end hides the seam between pieces.\n line: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1),\n }))\n )\n order = vines\n .flatMap((vine, v) =>\n vine.depths.map((depth, index) => ({ vine: v, index, depth }))\n )\n .sort((a, b) => a.depth - b.depth)\n }\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w === size.w && h === size.h && dpr === size.dpr) return\n Object.assign(size, { w, h, dpr })\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n rebuild()\n wake()\n }\n\n function draw() {\n const { w, h, dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.clearRect(0, 0, w, h)\n if (!vines.length || grow <= 0) return\n\n const eased = 1 - (1 - grow) ** 3\n // Clear space each side of a line where it crosses over another.\n const gap = thickness + Math.max(2, thickness * 1.1) * 2\n ctx!.strokeStyle =\n options.color?.trim() || getComputedStyle(canvas).color || \"#000\"\n ctx!.lineJoin = \"round\"\n ctx!.lineCap = \"round\"\n\n for (const { vine: v, index } of order) {\n const vine = vines[v]!\n const reach = ((vine.points.length / 2 - 1) * eased) | 0\n const a = vine.cuts[index]!\n if (a >= reach) continue\n const b = vine.cuts[index + 1]!\n // Only the growing tip needs a fresh path.\n const path =\n b <= reach\n ? paths[v]![index]!\n : {\n gap: tracePath(vine, a, reach),\n line: tracePath(vine, a - 1, reach),\n }\n\n ctx!.globalCompositeOperation = \"destination-out\"\n ctx!.lineWidth = gap\n ctx!.lineCap = \"butt\"\n ctx!.stroke(path.gap)\n ctx!.globalCompositeOperation = \"source-over\"\n ctx!.lineWidth = thickness\n ctx!.lineCap = \"round\"\n ctx!.stroke(path.line)\n }\n }\n\n function wake() {\n if (raf) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n function tick(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (mqReduce.matches) grow = seen ? 1 : grow\n else if (seen && document.visibilityState !== \"hidden\")\n grow = Math.min(1, grow + dt / GROW_S)\n draw()\n // Frames only while growing; afterwards it's a still drawing.\n if (seen && grow < 1) raf = requestAnimationFrame(tick)\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(\n ([entry]) => {\n if (!entry?.isIntersecting || seen) return\n seen = true\n io.disconnect()\n wake()\n },\n { threshold: 0.25 }\n )\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n // `currentColor` follows the theme; redraw when it flips.\n const mo = new MutationObserver(() => wake())\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\", wake)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (\n prev.vines !== options.vines ||\n prev.seed !== options.seed ||\n prev.thickness !== options.thickness\n ) {\n rebuild()\n // A new tangle grows in again.\n if (!mqReduce.matches) grow = 0\n }\n wake()\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqDark.removeEventListener(\"change\", wake)\n },\n }\n}\n\nexport type TangleFooterProps = TangleFooterOptions & {\n className?: string\n /** Footer height in px. Default `320`. */\n height?: number\n}\n\n/**\n * A footer of tangled vines — pen lines that curl into loops and cross over\n * and under each other. Transparent, in the text color, so it sits on any\n * page and follows light and dark. Grows in once when first seen.\n */\nexport function TangleFooter({\n className,\n color,\n height = 320,\n vines = DEFAULT_VINES,\n thickness,\n seed = DEFAULT_SEED,\n}: TangleFooterProps) {\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 = createTangleFooter(canvas, {\n color,\n vines,\n thickness,\n seed,\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({ color, vines, thickness, seed })\n }, [color, vines, thickness, seed])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/tangle-footer.tsx" } diff --git a/registry/tangle-footer/registry.json b/registry/tangle-footer/registry.json index 92a7f66..db60ff8 100644 --- a/registry/tangle-footer/registry.json +++ b/registry/tangle-footer/registry.json @@ -5,7 +5,7 @@ "name": "tangle-footer", "type": "registry:ui", "title": "Tangle Footer", - "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", + "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", "files": [ { "path": "tangle-footer.tsx", @@ -18,7 +18,7 @@ "name": "tangle-footer-svelte", "type": "registry:ui", "title": "Tangle Footer", - "description": "A footer of tangled vines — ribbons curl into loops and weave over and under each other, with your copy running along them.", + "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", "files": [ { "path": "tangle-footer.svelte", diff --git a/registry/tangle-footer/tangle-footer-demo.tsx b/registry/tangle-footer/tangle-footer-demo.tsx index 35e4d82..e68399e 100644 --- a/registry/tangle-footer/tangle-footer-demo.tsx +++ b/registry/tangle-footer/tangle-footer-demo.tsx @@ -1,113 +1,38 @@ "use client" -import { useLayoutEffect, useMemo } from "react" +import { useMemo } from "react" import { ComponentControls, - ControlColor, 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 { cn } from "@/lib/utils" import { DEFAULT_SEED, - DEFAULT_SPEED, DEFAULT_VINES, TangleFooter, } from "@/registry/tangle-footer/tangle-footer" -/** Matches TangleFooter theme defaults (uppercase for color inputs). */ -const LIGHT = { - ribbon: "#141414", - textColor: "#F4F0E8", - stage: "#EFEAE2", -} -const DARK = { - ribbon: "#E8E4DC", - textColor: "#161616", - stage: "#121210", -} - -type ThemePalette = typeof LIGHT - -function norm(hex: string) { - return hex.trim().toUpperCase() -} - -function matchesPalette( - ribbon: string, - textColor: string, - stage: string, - palette: ThemePalette -) { - return ( - norm(ribbon) === norm(palette.ribbon) && - norm(textColor) === norm(palette.textColor) && - norm(stage) === norm(palette.stage) - ) -} - -/** True when colors are still one of the built-in theme triples. */ -function isStockThemePalette(ribbon: string, textColor: string, stage: string) { - return ( - matchesPalette(ribbon, textColor, stage, LIGHT) || - matchesPalette(ribbon, textColor, stage, DARK) - ) -} - -const LINES = [ - "Ship something opinionated — less boilerplate, clearer decisions. ", - "Knows what’s going on. Can you check in with them and see what’s next. ", - "The new timeline should be ready by Friday, although it’s probably going to slip. ", - "Open the docs, grab a component, and make it yours in the codebase. ", - "Radiant lines, shader wash, gooey picker — install what you need and move. ", -] +/** Where the Thickness slider sits while the footer picks its own width. */ +const AUTO_THICKNESS = 2.5 export function TangleFooterDemo() { - const theme = useHydratedTheme() - const palette: ThemePalette = theme === "dark" ? DARK : LIGHT - const defaults = useMemo( () => ({ height: 320, vines: DEFAULT_VINES, - speed: DEFAULT_SPEED, + thickness: AUTO_THICKNESS, seed: DEFAULT_SEED, - ribbon: palette.ribbon, - textColor: palette.textColor, - stage: palette.stage, }), - [palette] + [] ) - const { props, updateProp, resetProps, hasChanges, setProps } = + const { props, updateProp, resetProps, hasChanges } = usePreviewProps(defaults) - // Keep stock ribbon/text/stage on the active theme until the user picks custom colors. - useLayoutEffect(() => { - setProps((prev) => { - if (!isStockThemePalette(prev.ribbon, prev.textColor, prev.stage)) { - return prev - } - if (matchesPalette(prev.ribbon, prev.textColor, prev.stage, palette)) { - return prev - } - return { - ...prev, - ribbon: palette.ribbon, - textColor: palette.textColor, - stage: palette.stage, - } - }) - }, [palette, setProps]) - - const useAutoTheme = isStockThemePalette( - props.ribbon, - props.textColor, - props.stage - ) + const thickness = + props.thickness === AUTO_THICKNESS ? undefined : props.thickness return ( <> @@ -116,25 +41,13 @@ export function TangleFooterDemo() { stageClassName="min-h-0 overflow-hidden p-0" align="start" > -
-
- -
+
+
@@ -145,31 +58,14 @@ export function TangleFooterDemo() { snippetProps={{ height: props.height === 320 ? undefined : props.height, vines: props.vines === DEFAULT_VINES ? undefined : props.vines, - speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, + thickness, seed: props.seed === DEFAULT_SEED ? undefined : props.seed, - ribbon: useAutoTheme ? undefined : props.ribbon, - textColor: useAutoTheme ? undefined : props.textColor, }} > - updateProp("stage", v)} - /> - updateProp("ribbon", v)} - /> - updateProp("textColor", v)} - /> updateProp("height", v)} @@ -183,12 +79,12 @@ export function TangleFooterDemo() { onChange={(v) => updateProp("vines", v)} /> updateProp("speed", v)} + label="Thickness" + value={props.thickness} + min={1} + max={6} + step={0.25} + onChange={(v) => updateProp("thickness", v)} /> void } -export const LIGHT_RIBBON = "#141414" -export const LIGHT_TEXT = "#F4F0E8" -export const LIGHT_BG = "#EFEAE2" -export const DARK_RIBBON = "#E8E4DC" -export const DARK_TEXT = "#161616" -export const DARK_BG = "#121210" -export const DEFAULT_VINES = 4 -export const DEFAULT_SPEED = 36 +export const DEFAULT_VINES = 3 export const DEFAULT_SEED = 23 /** Spacing of the resampled vine, in px. */ @@ -55,17 +27,10 @@ const GROW_S = 1.8 export type Vine = { /** x, y pairs spaced `STEP` apart along the vine. */ points: Float32Array - /** Arc length in px. */ - length: number /** Where each piece of the vine starts, in samples, plus the end. */ cuts: number[] /** Stacking order of each piece; higher draws on top. */ depths: number[] - /** The copy that repeats along this vine. */ - text: string - /** Marquee speed multiplier and starting offset (0–1 of the copy). */ - pace: number - phase: number } export function mulberry32(seed: number) { @@ -99,9 +64,9 @@ function wander(rand: () => number, scale: number) { waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total } -/** Ribbon width that suits a footer this wide. */ +/** A pen-line width that suits a footer this wide. */ export function autoThickness(width: number) { - return clamp(width * 0.019, 16, 28) + return clamp(width * 0.0021, 1.75, 3.5) } /** @@ -117,21 +82,15 @@ export function autoThickness(width: number) { export function buildVines( width: number, height: number, - options: { - vines?: number - thickness?: number - seed?: number - lines?: string[] - } = {} + options: Pick = {} ): Vine[] { const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12)) - const w = numberOr(options.thickness, autoThickness(width), 6, 80) const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31)) - const pool = options.lines?.filter((line) => line.trim()) ?? [] - const lines = pool.length ? pool : DEFAULT_LINES - // Pieces short enough that the two strands of any loop land in different - // pieces, so a loop can cross over itself. - const piece = w * 4 + // Smallest loop the wheel sizes produce (spoke ≈ 2 wheels at the low end). + const minLoop = height * 0.14 + // Pieces shorter than half the smallest loop, so the two strands of any + // loop land in different pieces and the loop can cross over itself. + const piece = minLoop * 1.5 return Array.from({ length: count }, (_, v) => { const rand = mulberry32(seed * 7919 + v * 104729) @@ -140,27 +99,30 @@ export function buildVines( const size = wander(rand, scale) const curl = wander(rand, scale * 0.8) // Spread the vines through the lower band; loops climb from there. - const floor = height * (0.6 + (v / Math.max(1, count - 1)) * 0.3) - const wheel0 = Math.max(w * 1.2, height * (0.07 + rand() * 0.04)) + const floor = height * (0.62 + (v / Math.max(1, count - 1)) * 0.28) + const wheel0 = height * (0.1 + rand() * 0.05) const raw: number[] = [] let phi = rand() * Math.PI * 2 const start = -width * OVERHANG const end = width * (1 + OVERHANG) for (let s = start; s <= end; s += 1) { - const y0 = floor + height * 0.2 * baseline(s) + const y0 = floor + height * 0.18 * baseline(s) const wheel = wheel0 * (1 + 0.45 * size(s)) // Past 1 the pen outruns the wheel and curls into a loop; near 2 the - // loop closes round. Below 1 the vine just waves. - let spoke = wheel * (1.5 + 1.05 * curl(s)) - // Open any loop too tight to read, then keep it under the top edge. - if (spoke > wheel) spoke = Math.max(spoke, w * 2.3) - spoke = Math.min(spoke, Math.max(w, (y0 - w * 0.6) / 2)) + // loop closes round. Below 1 the vine just waves. The ratio snaps + // quickly between the two, since loops just past 1 read as thin + // teardrops and waves just under 1 as sharp cusps. + const c = curl(s) + const k = clamp((c + 0.1) / 0.25, 0, 1) + let spoke = wheel * (0.7 + 1.35 * k * k * (3 - 2 * k) + 0.25 * c) + // Keep every loop under the top edge. + spoke = Math.min(spoke, Math.max(minLoop * 0.5, (y0 - height * 0.06) / 2)) phi += 1 / wheel raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi))) } - // Resample to even spacing so text and pieces are measured in px. + // Resample to even spacing so pieces are measured in px. const points: number[] = [raw[0]!, raw[1]!] let carry = 0 for (let i = 2; i < raw.length; i += 2) { @@ -183,50 +145,21 @@ export function buildVines( const cuts: number[] = [] for (let i = 0; i < samples - 1; i += per) cuts.push(i) cuts.push(samples - 1) - const depths = cuts.slice(0, -1).map(() => rand()) - - const line = lines[Math.floor(rand() * lines.length)]! - const other = lines[Math.floor(rand() * lines.length)]! - const text = - line === other ? `${line} · ` : `${line} · ${other} · ` return { points: Float32Array.from(points), - length: (samples - 1) * STEP, cuts, - depths, - text, - pace: 0.8 + rand() * 0.4, - phase: rand(), + depths: cuts.slice(0, -1).map(() => rand()), } }) } -/** 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: TangleFooterTheme = "auto"): boolean { - if (theme === "dark") return true - if (theme === "light") return false - return isDarkTheme() -} - -type Glyph = { ch: string; adv: number } type Piece = { vine: number; index: number; depth: number } /** - * Tangled text vines on a canvas. The vines grow in from the left the first - * time they're seen, then the copy runs along them. Pauses off-screen and in - * hidden tabs; holds still under `prefers-reduced-motion`. + * Tangled pen-line vines on a transparent canvas, in the text color by + * default. They grow in from the left the first time they're seen, then + * hold still — nothing runs after that. Redraws on resize and theme change. */ export function createTangleFooter( canvas: HTMLCanvasElement, @@ -239,32 +172,38 @@ export function createTangleFooter( const size = { w: 0, h: 0, dpr: 1 } let vines: Vine[] = [] let order: Piece[] = [] - let glyphs: Glyph[][] = [] - let units: number[] = [] - /** Cached gap and ribbon paths per piece; the geometry never moves. */ - let paths: { gap: Path2D; ribbon: Path2D }[][] = [] - let thickness = 20 - let font = "" - let dark = resolveDark(options.theme) + /** Cached gap and line paths per piece; the geometry never moves. */ + let paths: { gap: Path2D; line: Path2D }[][] = [] + let thickness = 2 let grow = 0 - let clock = 0 let seen = false - let onScreen = false let raf = 0 let last = 0 const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - let reduce = mqReduce.matches + + /** The stretch of a vine from sample `a` to `b` as a path. */ + function tracePath(vine: Vine, a: number, b: number) { + const p = vine.points + const end = p.length / 2 - 1 + const from = clamp(Math.floor(a), 0, end) + const to = clamp(Math.ceil(b), 0, end) + const path = new Path2D() + path.moveTo(p[from * 2]!, p[from * 2 + 1]!) + for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!) + return path + } const rebuild = () => { const { w, h } = size if (w < 2 || h < 2) return - thickness = numberOr(options.thickness, autoThickness(w), 6, 80) - vines = buildVines(w, h, { ...options, thickness }) + thickness = numberOr(options.thickness, autoThickness(w), 0.5, 24) + vines = buildVines(w, h, options) paths = vines.map((vine) => vine.depths.map((_, k) => ({ gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!), - ribbon: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1), + // A sample past each end hides the seam between pieces. + line: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1), })) ) order = vines @@ -272,21 +211,6 @@ export function createTangleFooter( vine.depths.map((depth, index) => ({ vine: v, index, depth })) ) .sort((a, b) => a.depth - b.depth) - const family = getComputedStyle(canvas).fontFamily || "sans-serif" - font = `700 ${Math.round(thickness * 0.5)}px ${family}` - ctx.font = font - const widths = new Map() - glyphs = vines.map((vine) => - Array.from(vine.text).map((ch) => { - let adv = widths.get(ch) - if (adv === undefined) { - adv = ctx.measureText(ch).width - widths.set(ch, adv) - } - return { ch, adv } - }) - ) - units = glyphs.map((list) => list.reduce((sum, g) => sum + g.adv, 0)) } const resize = () => { @@ -303,125 +227,46 @@ export function createTangleFooter( wake() } - /** The stretch of a vine from sample `a` to `b` as a path. */ - function tracePath(vine: Vine, a: number, b: number) { - const p = vine.points - const last = p.length / 2 - 1 - const from = clamp(Math.floor(a), 0, last) - const to = clamp(Math.ceil(b), 0, last) - const path = new Path2D() - path.moveTo(p[from * 2]!, p[from * 2 + 1]!) - for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!) - return path - } - function draw() { const { w, h, dpr } = size ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) ctx!.clearRect(0, 0, w, h) - if (!vines.length) return + if (!vines.length || grow <= 0) return - const ribbon = options.ribbon?.trim() || (dark ? DARK_RIBBON : LIGHT_RIBBON) - const ink = options.textColor?.trim() || (dark ? DARK_TEXT : LIGHT_TEXT) - const halo = Math.max(2.5, thickness * 0.16) - const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 400) const eased = 1 - (1 - grow) ** 3 - - // Lay the copy along each vine and hand every glyph to the pieces it - // touches, so a piece drawn on top never clips a neighbour's letters. - const placed: [number, number][][][] = vines.map((vine) => - vine.depths.map(() => []) - ) - vines.forEach((vine, v) => { - const list = glyphs[v]! - const unit = units[v]! - if (!list.length || unit <= 0) return - const reach = vine.length * eased - const shift = (clock * speed * vine.pace + vine.phase * unit) % unit - let at = -shift - let i = 0 - while (at < reach) { - const adv = list[i]!.adv - if (at + adv > 0 && list[i]!.ch !== " ") { - const mid = at + adv / 2 - if (mid < reach) { - // Pieces are evenly spaced, so the ones a glyph spans are direct. - const per = vine.cuts[1]! - vine.cuts[0]! - const lastPiece = vine.depths.length - 1 - const k0 = Math.min( - lastPiece, - Math.floor(Math.max(0, at) / STEP / per) - ) - const k1 = Math.min(lastPiece, Math.floor((at + adv) / STEP / per)) - for (let k = k0; k <= k1; k++) placed[v]![k]!.push([mid, i]) - } - } - at += adv - i = (i + 1) % list.length - } - }) - + // Clear space each side of a line where it crosses over another. + const gap = thickness + Math.max(2, thickness * 1.1) * 2 + ctx!.strokeStyle = + options.color?.trim() || getComputedStyle(canvas).color || "#000" ctx!.lineJoin = "round" - ctx!.lineCap = "butt" - ctx!.font = font - ctx!.textBaseline = "middle" - ctx!.textAlign = "center" + ctx!.lineCap = "round" + for (const { vine: v, index } of order) { const vine = vines[v]! - const reach = (vine.length * eased) / STEP + const reach = ((vine.points.length / 2 - 1) * eased) | 0 const a = vine.cuts[index]! if (a >= reach) continue const b = vine.cuts[index + 1]! // Only the growing tip needs a fresh path. - const whole = b <= reach - const path = whole - ? paths[v]![index]! - : { - gap: tracePath(vine, a, reach), - ribbon: tracePath(vine, a - 1, reach), - } + const path = + b <= reach + ? paths[v]![index]! + : { + gap: tracePath(vine, a, reach), + line: tracePath(vine, a - 1, reach), + } - // Cut a gap around this piece, so whatever it crosses reads as beneath. ctx!.globalCompositeOperation = "destination-out" - ctx!.lineWidth = thickness + halo * 2 + ctx!.lineWidth = gap + ctx!.lineCap = "butt" ctx!.stroke(path.gap) ctx!.globalCompositeOperation = "source-over" - ctx!.strokeStyle = ribbon ctx!.lineWidth = thickness - // The ribbon runs a sample past each end, hiding the seam between pieces. - ctx!.stroke(path.ribbon) - - ctx!.fillStyle = ink - const p = vine.points - const lastSample = p.length / 2 - 1 - for (const [mid, gi] of placed[v]![index]!) { - const s = mid / STEP - const i0 = clamp(Math.floor(s), 0, lastSample - 1) - const k = s - i0 - const x0 = p[i0 * 2]! - const y0 = p[i0 * 2 + 1]! - const x1 = p[i0 * 2 + 2]! - const y1 = p[i0 * 2 + 3]! - const angle = Math.atan2(y1 - y0, x1 - x0) - const cos = Math.cos(angle) - const sin = Math.sin(angle) - ctx!.setTransform( - dpr * cos, - dpr * sin, - -dpr * sin, - dpr * cos, - dpr * (x0 + (x1 - x0) * k), - dpr * (y0 + (y1 - y0) * k) - ) - ctx!.fillText(glyphs[v]![gi]!.ch, 0, 0) - } - ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx!.lineCap = "round" + ctx!.stroke(path.line) } } - const animating = () => - onScreen && !reduce && document.visibilityState !== "hidden" - function wake() { if (raf) return last = performance.now() @@ -432,51 +277,36 @@ export function createTangleFooter( raf = 0 const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) last = now - const moving = animating() - if (reduce) grow = 1 - else if (seen && moving) grow = Math.min(1, grow + dt / GROW_S) - if (moving) clock += dt + if (mqReduce.matches) grow = seen ? 1 : grow + else if (seen && document.visibilityState !== "hidden") + grow = Math.min(1, grow + dt / GROW_S) draw() - if (moving) raf = requestAnimationFrame(tick) - } - - const syncTheme = () => { - const next = resolveDark(options.theme) - if (next === dark) return - dark = next - wake() + // Frames only while growing; afterwards it's a still drawing. + if (seen && grow < 1) raf = requestAnimationFrame(tick) } const ro = new ResizeObserver(resize) if (canvas.parentElement) ro.observe(canvas.parentElement) const io = new IntersectionObserver( ([entry]) => { - onScreen = entry?.isIntersecting ?? true - if (onScreen) seen = true + if (!entry?.isIntersecting || seen) return + seen = true + io.disconnect() wake() }, - { rootMargin: "64px" } + { threshold: 0.25 } ) io.observe(canvas) const onVisibility = () => wake() document.addEventListener("visibilitychange", onVisibility) - const onReduce = () => { - reduce = mqReduce.matches - wake() - } - mqReduce.addEventListener("change", onReduce) - const mo = new MutationObserver(syncTheme) + // `currentColor` follows the theme; redraw when it flips. + const mo = new MutationObserver(() => wake()) mo.observe(document.documentElement, { attributes: true, - attributeFilter: ["class", "data-theme"], + attributeFilter: ["class", "data-theme", "style"], }) const mqDark = window.matchMedia("(prefers-color-scheme: dark)") - mqDark.addEventListener("change", syncTheme) - // Canvas text can't wait on a web font by itself. - document.fonts?.ready.then(() => { - rebuild() - wake() - }) + mqDark.addEventListener("change", wake) resize() wake() @@ -485,14 +315,14 @@ export function createTangleFooter( setOptions(next) { const prev = options options = { ...options, ...next } - syncTheme() if ( - prev.lines !== options.lines || prev.vines !== options.vines || prev.seed !== options.seed || prev.thickness !== options.thickness ) { rebuild() + // A new tangle grows in again. + if (!mqReduce.matches) grow = 0 } wake() }, @@ -503,8 +333,7 @@ export function createTangleFooter( io.disconnect() mo.disconnect() document.removeEventListener("visibilitychange", onVisibility) - mqReduce.removeEventListener("change", onReduce) - mqDark.removeEventListener("change", syncTheme) + mqDark.removeEventListener("change", wake) }, } } diff --git a/registry/tangle-footer/tangle-footer.svelte b/registry/tangle-footer/tangle-footer.svelte index f0762ae..4598b58 100644 --- a/registry/tangle-footer/tangle-footer.svelte +++ b/registry/tangle-footer/tangle-footer.svelte @@ -5,13 +5,10 @@ import { onMount, untrack } from "svelte" import { createTangleFooter, - DEFAULT_LINES, DEFAULT_SEED, - DEFAULT_SPEED, DEFAULT_VINES, type TangleFooterInstance, type TangleFooterOptions, - type TangleFooterTheme, } from "./tangle-footer-vanilla" function cn(...parts: Array) { @@ -20,40 +17,19 @@ interface Props extends TangleFooterOptions { class?: string - /** - * Field behind the vines. Omit for theme-aware defaults (warm cream on - * light, near-black on dark). Pass `"transparent"` when the parent - * already paints the stage. - */ - background?: string /** Footer height in px. Default `320`. */ height?: number - /** Accessible label. */ - label?: string } let { class: className = "", - lines = DEFAULT_LINES, - ribbon, - textColor, - background, + color, height = 320, vines = DEFAULT_VINES, - speed = DEFAULT_SPEED, thickness, seed = DEFAULT_SEED, - theme = "auto", - label = "Site footer", }: Props = $props() - /** Stage color before and behind the canvas; hex literals for Tailwind. */ - const SURFACE: Record = { - auto: "bg-[#EFEAE2] dark:bg-[#121210]", - dark: "bg-[#121210]", - light: "bg-[#EFEAE2]", - } - let canvas: HTMLCanvasElement | undefined = $state() let instance: TangleFooterInstance | null = null @@ -61,16 +37,7 @@ if (!canvas) return instance = createTangleFooter( canvas, - untrack(() => ({ - lines, - ribbon, - textColor, - vines, - speed, - thickness, - seed, - theme, - })) + untrack(() => ({ color, vines, thickness, seed })) ) return () => { instance?.destroy() @@ -79,31 +46,15 @@ }) $effect(() => { - instance?.setOptions({ - lines, - ribbon, - textColor, - vines, - speed, - thickness, - seed, - theme, - }) + instance?.setOptions({ color, vines, thickness, seed }) })
-

{lines.join(" ")}

diff --git a/registry/tangle-footer/tangle-footer.tsx b/registry/tangle-footer/tangle-footer.tsx index f39053e..9c944a6 100644 --- a/registry/tangle-footer/tangle-footer.tsx +++ b/registry/tangle-footer/tangle-footer.tsx @@ -6,72 +6,36 @@ import { cn } from "@/lib/utils" import { createTangleFooter, - DEFAULT_LINES, DEFAULT_SEED, - DEFAULT_SPEED, DEFAULT_VINES, type TangleFooterInstance, type TangleFooterOptions, - type TangleFooterTheme, } from "./tangle-footer-vanilla" -export { - DARK_BG, - DARK_RIBBON, - DARK_TEXT, - DEFAULT_LINES, - DEFAULT_SEED, - DEFAULT_SPEED, - DEFAULT_VINES, - LIGHT_BG, - LIGHT_RIBBON, - LIGHT_TEXT, -} from "./tangle-footer-vanilla" +export { DEFAULT_SEED, DEFAULT_VINES } from "./tangle-footer-vanilla" export type { TangleFooterInstance, TangleFooterOptions, - TangleFooterTheme, } from "./tangle-footer-vanilla" export type TangleFooterProps = TangleFooterOptions & { className?: string - /** - * Field behind the vines. Omit for theme-aware defaults (warm cream on - * light, near-black on dark). Pass `"transparent"` when the parent - * already paints the stage. - */ - background?: string /** Footer height in px. Default `320`. */ height?: number - /** Accessible label. */ - label?: string -} - -/** Stage color before and behind the canvas; hex literals for Tailwind. */ -const SURFACE: Record = { - auto: "bg-[#EFEAE2] dark:bg-[#121210]", - dark: "bg-[#121210]", - light: "bg-[#EFEAE2]", } /** - * A footer of tangled vines — thick ribbons that curl into loops and weave - * over and under each other, with your copy running along them. The vines - * grow in the first time the footer is seen. + * A footer of tangled vines — pen lines that curl into loops and cross over + * and under each other. Transparent, in the text color, so it sits on any + * page and follows light and dark. Grows in once when first seen. */ export function TangleFooter({ className, - lines = DEFAULT_LINES, - ribbon, - textColor, - background, + color, height = 320, vines = DEFAULT_VINES, - speed = DEFAULT_SPEED, thickness, seed = DEFAULT_SEED, - theme = "auto", - label = "Site footer", }: TangleFooterProps) { const canvasRef = useRef(null) const instanceRef = useRef(null) @@ -80,14 +44,10 @@ export function TangleFooter({ const canvas = canvasRef.current if (!canvas) return instanceRef.current = createTangleFooter(canvas, { - lines, - ribbon, - textColor, + color, vines, - speed, thickness, seed, - theme, }) return () => { instanceRef.current?.destroy() @@ -98,35 +58,20 @@ export function TangleFooter({ }, []) useEffect(() => { - instanceRef.current?.setOptions({ - lines, - ribbon, - textColor, - vines, - speed, - thickness, - seed, - theme, - }) - }, [lines, ribbon, textColor, vines, speed, thickness, seed, theme]) + instanceRef.current?.setOptions({ color, vines, thickness, seed }) + }, [color, vines, thickness, seed]) return (
-

{lines.join(" ")}

) } From 332e1597c4c024fd47c5459e5233d93916073119 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:44:54 +0530 Subject: [PATCH 18/49] Remove Tangle Footer. Drops the component, its docs page, registry items, OG image and every mention in the README, Getting Started and the 23rd skill. The skill's dependency table now lists motion for gooey-color-picker only, since Stretchy Footer no longer needs it. --- .cursor/skills/23rd/SKILL.md | 37 +- .cursor/skills/23rd/apis.md | 24 -- .cursor/skills/23rd/components.md | 23 +- .cursor/skills/23rd/recipes.md | 26 -- README.md | 1 - content/docs/components/meta.json | 1 - content/docs/components/tangle-footer.mdx | 59 --- content/docs/getting-started.mdx | 4 +- .../docs/components/tangle-footer/image.png | Bin 47033 -> 0 bytes public/r/registry.json | 27 -- public/r/tangle-footer-svelte.json | 16 - public/r/tangle-footer.json | 15 - registry.json | 3 +- registry/tangle-footer/registry.json | 32 -- registry/tangle-footer/tangle-footer-demo.tsx | 100 ------ .../tangle-footer/tangle-footer-vanilla.ts | 339 ------------------ registry/tangle-footer/tangle-footer.svelte | 60 ---- registry/tangle-footer/tangle-footer.tsx | 77 ---- skills/23rd/SKILL.md | 37 +- skills/23rd/apis.md | 24 -- skills/23rd/components.md | 23 +- skills/23rd/recipes.md | 26 -- 22 files changed, 13 insertions(+), 941 deletions(-) delete mode 100644 content/docs/components/tangle-footer.mdx delete mode 100644 public/og/docs/components/tangle-footer/image.png delete mode 100644 public/r/tangle-footer-svelte.json delete mode 100644 public/r/tangle-footer.json delete mode 100644 registry/tangle-footer/registry.json delete mode 100644 registry/tangle-footer/tangle-footer-demo.tsx delete mode 100644 registry/tangle-footer/tangle-footer-vanilla.ts delete mode 100644 registry/tangle-footer/tangle-footer.svelte delete mode 100644 registry/tangle-footer/tangle-footer.tsx diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index 74c8f92..caca221 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -74,7 +74,7 @@ npm dependencies the registry declares: | Item | React | Svelte | | --- | --- | --- | -| `gooey-color-picker`, `tangle-footer`, `stretchy-footer` | `motion` | none | +| `gooey-color-picker` | `motion` | none | | every other item | none | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -99,7 +99,6 @@ flowchart TD atmo -->|Hair-line explosion| lb[logo-burst] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] - foot -->|Spinning text ribbons| tf[tangle-footer] mark -->|Eyes that track the pointer| orb[live-orb] mark -->|ASCII wordmark, click to scatter| al[ascii-logo] ``` @@ -109,11 +108,10 @@ Disambiguation that agents get wrong: | User says | Use | Not | | --- | --- | --- | | Color picker, swatch, hex, hue, alpha, eyedropper | `gooey-color-picker` | Any shader. Shaders are not controls. | -| Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker`, `tangle-footer` | +| Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | | Fire, embers, heat under a hero | `shader-fire` | `dithered-404` unless the page is a 404 | | 404 that burns under the cursor | `dithered-404` | `shader-fire` | -| Footer of nested spinning sentences | `tangle-footer` | `stretchy-footer` | -| Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `tangle-footer` | +| Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | 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` | @@ -140,8 +138,6 @@ Same shell for `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorSc `Folio` and `StretchyFooter` are the scroller by default. Put the page in `children`. For a real document, set `windowScroll` and mark the tilting or lifting element (`data-folio-page` or `data-stretchy-page`). -`TangleFooter` is a `\n", - "type": "registry:file", - "target": "src/lib/components/ui/tangle-footer.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/tangle-footer.json b/public/r/tangle-footer.json deleted file mode 100644 index 70d6be0..0000000 --- a/public/r/tangle-footer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "tangle-footer", - "title": "Tangle Footer", - "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", - "files": [ - { - "path": "registry/tangle-footer/tangle-footer.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type TangleFooterOptions = {\n /** Line color, any CSS color. Omit to use the text color (`currentColor`). */\n color?: string\n /** How many vines. Default `3`. */\n vines?: number\n /** Line width in px. Omit to scale with the footer width. */\n thickness?: number\n /** Deterministic tangle. Default `23`. */\n seed?: number\n}\n\nexport type TangleFooterInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_VINES = 3\nexport const DEFAULT_SEED = 23\n\n/** Spacing of the resampled vine, in px. */\nconst STEP = 2\n/** Vines start and end this share of the width past each side. */\nconst OVERHANG = 0.12\n/** Seconds for the vines to grow in. */\nconst GROW_S = 1.8\n\nexport type Vine = {\n /** x, y pairs spaced `STEP` apart along the vine. */\n points: Float32Array\n /** Where each piece of the vine starts, in samples, plus the end. */\n cuts: number[]\n /** Stacking order of each piece; higher draws on top. */\n depths: number[]\n}\n\nexport function mulberry32(seed: number) {\n let t = seed >>> 0\n return () => {\n t += 0x6d2b79f5\n let r = Math.imul(t ^ (t >>> 15), 1 | t)\n r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)\n return ((r ^ (r >>> 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 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/** Smooth wander in −1…1 from three detuned sines. */\nfunction wander(rand: () => number, scale: number) {\n const waves = Array.from({ length: 3 }, (_, i) => ({\n f: ((0.6 + rand() * 0.8) * (i + 1)) / scale,\n p: rand() * Math.PI * 2,\n a: 1 / (i + 1),\n }))\n const total = waves.reduce((sum, w) => sum + w.a, 0)\n return (s: number) =>\n waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total\n}\n\n/** A pen-line width that suits a footer this wide. */\nexport function autoThickness(width: number) {\n return clamp(width * 0.0021, 1.75, 3.5)\n}\n\n/**\n * Lays out the tangle. Each vine is a wheel rolling left to right with a\n * pen on a spoke longer than the wheel (a prolate trochoid): where the pen\n * outruns the wheel it curls back into a loop, so loops rise off a\n * wandering baseline. Wheel size and spoke length drift along the vine,\n * so some curls are big, some small, some just a wave.\n *\n * Every vine is cut into short pieces with random stacking, so vines pass\n * over and under each other — and themselves — like a real tangle.\n */\nexport function buildVines(\n width: number,\n height: number,\n options: Pick = {}\n): Vine[] {\n const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12))\n const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31))\n // Smallest loop the wheel sizes produce (spoke ≈ 2 wheels at the low end).\n const minLoop = height * 0.14\n // Pieces shorter than half the smallest loop, so the two strands of any\n // loop land in different pieces and the loop can cross over itself.\n const piece = minLoop * 1.5\n\n return Array.from({ length: count }, (_, v) => {\n const rand = mulberry32(seed * 7919 + v * 104729)\n const scale = width * (0.18 + rand() * 0.12)\n const baseline = wander(rand, scale * 1.6)\n const size = wander(rand, scale)\n const curl = wander(rand, scale * 0.8)\n // Spread the vines through the lower band; loops climb from there.\n const floor = height * (0.62 + (v / Math.max(1, count - 1)) * 0.28)\n const wheel0 = height * (0.1 + rand() * 0.05)\n\n const raw: number[] = []\n let phi = rand() * Math.PI * 2\n const start = -width * OVERHANG\n const end = width * (1 + OVERHANG)\n for (let s = start; s <= end; s += 1) {\n const y0 = floor + height * 0.18 * baseline(s)\n const wheel = wheel0 * (1 + 0.45 * size(s))\n // Past 1 the pen outruns the wheel and curls into a loop; near 2 the\n // loop closes round. Below 1 the vine just waves. The ratio snaps\n // quickly between the two, since loops just past 1 read as thin\n // teardrops and waves just under 1 as sharp cusps.\n const c = curl(s)\n const k = clamp((c + 0.1) / 0.25, 0, 1)\n let spoke = wheel * (0.7 + 1.35 * k * k * (3 - 2 * k) + 0.25 * c)\n // Keep every loop under the top edge.\n spoke = Math.min(spoke, Math.max(minLoop * 0.5, (y0 - height * 0.06) / 2))\n phi += 1 / wheel\n raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi)))\n }\n\n // Resample to even spacing so pieces are measured in px.\n const points: number[] = [raw[0]!, raw[1]!]\n let carry = 0\n for (let i = 2; i < raw.length; i += 2) {\n const ax = raw[i - 2]!\n const ay = raw[i - 1]!\n const bx = raw[i]!\n const by = raw[i + 1]!\n const seg = Math.hypot(bx - ax, by - ay)\n let at = STEP - carry\n while (at <= seg) {\n const k = at / seg\n points.push(ax + (bx - ax) * k, ay + (by - ay) * k)\n at += STEP\n }\n carry = seg - (at - STEP)\n }\n\n const samples = points.length / 2\n const per = Math.max(2, Math.round(piece / STEP))\n const cuts: number[] = []\n for (let i = 0; i < samples - 1; i += per) cuts.push(i)\n cuts.push(samples - 1)\n\n return {\n points: Float32Array.from(points),\n cuts,\n depths: cuts.slice(0, -1).map(() => rand()),\n }\n })\n}\n\ntype Piece = { vine: number; index: number; depth: number }\n\n/**\n * Tangled pen-line vines on a transparent canvas, in the text color by\n * default. They grow in from the left the first time they're seen, then\n * hold still — nothing runs after that. Redraws on resize and theme change.\n */\nexport function createTangleFooter(\n canvas: HTMLCanvasElement,\n initial: TangleFooterOptions = {}\n): TangleFooterInstance | null {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: TangleFooterOptions = { ...initial }\n const size = { w: 0, h: 0, dpr: 1 }\n let vines: Vine[] = []\n let order: Piece[] = []\n /** Cached gap and line paths per piece; the geometry never moves. */\n let paths: { gap: Path2D; line: Path2D }[][] = []\n let thickness = 2\n let grow = 0\n let seen = false\n let raf = 0\n let last = 0\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n\n /** The stretch of a vine from sample `a` to `b` as a path. */\n function tracePath(vine: Vine, a: number, b: number) {\n const p = vine.points\n const end = p.length / 2 - 1\n const from = clamp(Math.floor(a), 0, end)\n const to = clamp(Math.ceil(b), 0, end)\n const path = new Path2D()\n path.moveTo(p[from * 2]!, p[from * 2 + 1]!)\n for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!)\n return path\n }\n\n const rebuild = () => {\n const { w, h } = size\n if (w < 2 || h < 2) return\n thickness = numberOr(options.thickness, autoThickness(w), 0.5, 24)\n vines = buildVines(w, h, options)\n paths = vines.map((vine) =>\n vine.depths.map((_, k) => ({\n gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!),\n // A sample past each end hides the seam between pieces.\n line: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1),\n }))\n )\n order = vines\n .flatMap((vine, v) =>\n vine.depths.map((depth, index) => ({ vine: v, index, depth }))\n )\n .sort((a, b) => a.depth - b.depth)\n }\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w === size.w && h === size.h && dpr === size.dpr) return\n Object.assign(size, { w, h, dpr })\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n rebuild()\n wake()\n }\n\n function draw() {\n const { w, h, dpr } = size\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx!.clearRect(0, 0, w, h)\n if (!vines.length || grow <= 0) return\n\n const eased = 1 - (1 - grow) ** 3\n // Clear space each side of a line where it crosses over another.\n const gap = thickness + Math.max(2, thickness * 1.1) * 2\n ctx!.strokeStyle =\n options.color?.trim() || getComputedStyle(canvas).color || \"#000\"\n ctx!.lineJoin = \"round\"\n ctx!.lineCap = \"round\"\n\n for (const { vine: v, index } of order) {\n const vine = vines[v]!\n const reach = ((vine.points.length / 2 - 1) * eased) | 0\n const a = vine.cuts[index]!\n if (a >= reach) continue\n const b = vine.cuts[index + 1]!\n // Only the growing tip needs a fresh path.\n const path =\n b <= reach\n ? paths[v]![index]!\n : {\n gap: tracePath(vine, a, reach),\n line: tracePath(vine, a - 1, reach),\n }\n\n ctx!.globalCompositeOperation = \"destination-out\"\n ctx!.lineWidth = gap\n ctx!.lineCap = \"butt\"\n ctx!.stroke(path.gap)\n ctx!.globalCompositeOperation = \"source-over\"\n ctx!.lineWidth = thickness\n ctx!.lineCap = \"round\"\n ctx!.stroke(path.line)\n }\n }\n\n function wake() {\n if (raf) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n function tick(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (mqReduce.matches) grow = seen ? 1 : grow\n else if (seen && document.visibilityState !== \"hidden\")\n grow = Math.min(1, grow + dt / GROW_S)\n draw()\n // Frames only while growing; afterwards it's a still drawing.\n if (seen && grow < 1) raf = requestAnimationFrame(tick)\n }\n\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n const io = new IntersectionObserver(\n ([entry]) => {\n if (!entry?.isIntersecting || seen) return\n seen = true\n io.disconnect()\n wake()\n },\n { threshold: 0.25 }\n )\n io.observe(canvas)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n // `currentColor` follows the theme; redraw when it flips.\n const mo = new MutationObserver(() => wake())\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\", wake)\n\n resize()\n wake()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (\n prev.vines !== options.vines ||\n prev.seed !== options.seed ||\n prev.thickness !== options.thickness\n ) {\n rebuild()\n // A new tangle grows in again.\n if (!mqReduce.matches) grow = 0\n }\n wake()\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqDark.removeEventListener(\"change\", wake)\n },\n }\n}\n\nexport type TangleFooterProps = TangleFooterOptions & {\n className?: string\n /** Footer height in px. Default `320`. */\n height?: number\n}\n\n/**\n * A footer of tangled vines — pen lines that curl into loops and cross over\n * and under each other. Transparent, in the text color, so it sits on any\n * page and follows light and dark. Grows in once when first seen.\n */\nexport function TangleFooter({\n className,\n color,\n height = 320,\n vines = DEFAULT_VINES,\n thickness,\n seed = DEFAULT_SEED,\n}: TangleFooterProps) {\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 = createTangleFooter(canvas, {\n color,\n vines,\n thickness,\n seed,\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({ color, vines, thickness, seed })\n }, [color, vines, thickness, seed])\n\n return (\n \n \n \n )\n}\n", - "type": "registry:ui", - "target": "components/ui/tangle-footer.tsx" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/registry.json b/registry.json index 306b41f..9a353dc 100644 --- a/registry.json +++ b/registry.json @@ -15,7 +15,6 @@ "registry/shader-fire/registry.json", "registry/shader-gradient/registry.json", "registry/shader-sky/registry.json", - "registry/stretchy-footer/registry.json", - "registry/tangle-footer/registry.json" + "registry/stretchy-footer/registry.json" ] } diff --git a/registry/tangle-footer/registry.json b/registry/tangle-footer/registry.json deleted file mode 100644 index db60ff8..0000000 --- a/registry/tangle-footer/registry.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry.json", - "items": [ - { - "name": "tangle-footer", - "type": "registry:ui", - "title": "Tangle Footer", - "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", - "files": [ - { - "path": "tangle-footer.tsx", - "type": "registry:ui", - "target": "components/ui/tangle-footer.tsx" - } - ] - }, - { - "name": "tangle-footer-svelte", - "type": "registry:ui", - "title": "Tangle Footer", - "description": "A footer of tangled vines — clean pen lines that curl into loops and cross over and under each other.", - "files": [ - { - "path": "tangle-footer.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/tangle-footer.svelte" - } - ], - "dependencies": [] - } - ] -} diff --git a/registry/tangle-footer/tangle-footer-demo.tsx b/registry/tangle-footer/tangle-footer-demo.tsx deleted file mode 100644 index e68399e..0000000 --- a/registry/tangle-footer/tangle-footer-demo.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client" - -import { useMemo } from "react" - -import { - ComponentControls, - ControlSlider, -} from "@/components/component-controls" -import { ComponentPreview } from "@/components/component-preview" -import { usePreviewProps } from "@/hooks/use-preview-props" -import { - DEFAULT_SEED, - DEFAULT_VINES, - TangleFooter, -} from "@/registry/tangle-footer/tangle-footer" - -/** Where the Thickness slider sits while the footer picks its own width. */ -const AUTO_THICKNESS = 2.5 - -export function TangleFooterDemo() { - const defaults = useMemo( - () => ({ - height: 320, - vines: DEFAULT_VINES, - thickness: AUTO_THICKNESS, - seed: DEFAULT_SEED, - }), - [] - ) - - const { props, updateProp, resetProps, hasChanges } = - usePreviewProps(defaults) - - const thickness = - props.thickness === AUTO_THICKNESS ? undefined : props.thickness - - return ( - <> - -
- -
-
- - - updateProp("height", v)} - /> - updateProp("vines", v)} - /> - updateProp("thickness", v)} - /> - updateProp("seed", v)} - /> - - - ) -} diff --git a/registry/tangle-footer/tangle-footer-vanilla.ts b/registry/tangle-footer/tangle-footer-vanilla.ts deleted file mode 100644 index f16905a..0000000 --- a/registry/tangle-footer/tangle-footer-vanilla.ts +++ /dev/null @@ -1,339 +0,0 @@ -export type TangleFooterOptions = { - /** Line color, any CSS color. Omit to use the text color (`currentColor`). */ - color?: string - /** How many vines. Default `3`. */ - vines?: number - /** Line width in px. Omit to scale with the footer width. */ - thickness?: number - /** Deterministic tangle. Default `23`. */ - seed?: number -} - -export type TangleFooterInstance = { - setOptions: (options: Partial) => void - destroy: () => void -} - -export const DEFAULT_VINES = 3 -export const DEFAULT_SEED = 23 - -/** Spacing of the resampled vine, in px. */ -const STEP = 2 -/** Vines start and end this share of the width past each side. */ -const OVERHANG = 0.12 -/** Seconds for the vines to grow in. */ -const GROW_S = 1.8 - -export type Vine = { - /** x, y pairs spaced `STEP` apart along the vine. */ - points: Float32Array - /** Where each piece of the vine starts, in samples, plus the end. */ - cuts: number[] - /** Stacking order of each piece; higher draws on top. */ - depths: number[] -} - -export function mulberry32(seed: number) { - let t = seed >>> 0 - return () => { - t += 0x6d2b79f5 - let r = Math.imul(t ^ (t >>> 15), 1 | t) - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r) - return ((r ^ (r >>> 14)) >>> 0) / 4294967296 - } -} - -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) -} - -/** Smooth wander in −1…1 from three detuned sines. */ -function wander(rand: () => number, scale: number) { - const waves = Array.from({ length: 3 }, (_, i) => ({ - f: ((0.6 + rand() * 0.8) * (i + 1)) / scale, - p: rand() * Math.PI * 2, - a: 1 / (i + 1), - })) - const total = waves.reduce((sum, w) => sum + w.a, 0) - return (s: number) => - waves.reduce((sum, w) => sum + w.a * Math.sin(w.f * s + w.p), 0) / total -} - -/** A pen-line width that suits a footer this wide. */ -export function autoThickness(width: number) { - return clamp(width * 0.0021, 1.75, 3.5) -} - -/** - * Lays out the tangle. Each vine is a wheel rolling left to right with a - * pen on a spoke longer than the wheel (a prolate trochoid): where the pen - * outruns the wheel it curls back into a loop, so loops rise off a - * wandering baseline. Wheel size and spoke length drift along the vine, - * so some curls are big, some small, some just a wave. - * - * Every vine is cut into short pieces with random stacking, so vines pass - * over and under each other — and themselves — like a real tangle. - */ -export function buildVines( - width: number, - height: number, - options: Pick = {} -): Vine[] { - const count = Math.round(numberOr(options.vines, DEFAULT_VINES, 1, 12)) - const seed = Math.round(numberOr(options.seed, DEFAULT_SEED, 0, 2 ** 31)) - // Smallest loop the wheel sizes produce (spoke ≈ 2 wheels at the low end). - const minLoop = height * 0.14 - // Pieces shorter than half the smallest loop, so the two strands of any - // loop land in different pieces and the loop can cross over itself. - const piece = minLoop * 1.5 - - return Array.from({ length: count }, (_, v) => { - const rand = mulberry32(seed * 7919 + v * 104729) - const scale = width * (0.18 + rand() * 0.12) - const baseline = wander(rand, scale * 1.6) - const size = wander(rand, scale) - const curl = wander(rand, scale * 0.8) - // Spread the vines through the lower band; loops climb from there. - const floor = height * (0.62 + (v / Math.max(1, count - 1)) * 0.28) - const wheel0 = height * (0.1 + rand() * 0.05) - - const raw: number[] = [] - let phi = rand() * Math.PI * 2 - const start = -width * OVERHANG - const end = width * (1 + OVERHANG) - for (let s = start; s <= end; s += 1) { - const y0 = floor + height * 0.18 * baseline(s) - const wheel = wheel0 * (1 + 0.45 * size(s)) - // Past 1 the pen outruns the wheel and curls into a loop; near 2 the - // loop closes round. Below 1 the vine just waves. The ratio snaps - // quickly between the two, since loops just past 1 read as thin - // teardrops and waves just under 1 as sharp cusps. - const c = curl(s) - const k = clamp((c + 0.1) / 0.25, 0, 1) - let spoke = wheel * (0.7 + 1.35 * k * k * (3 - 2 * k) + 0.25 * c) - // Keep every loop under the top edge. - spoke = Math.min(spoke, Math.max(minLoop * 0.5, (y0 - height * 0.06) / 2)) - phi += 1 / wheel - raw.push(s + spoke * Math.sin(phi), y0 - spoke * (1 - Math.cos(phi))) - } - - // Resample to even spacing so pieces are measured in px. - const points: number[] = [raw[0]!, raw[1]!] - let carry = 0 - for (let i = 2; i < raw.length; i += 2) { - const ax = raw[i - 2]! - const ay = raw[i - 1]! - const bx = raw[i]! - const by = raw[i + 1]! - const seg = Math.hypot(bx - ax, by - ay) - let at = STEP - carry - while (at <= seg) { - const k = at / seg - points.push(ax + (bx - ax) * k, ay + (by - ay) * k) - at += STEP - } - carry = seg - (at - STEP) - } - - const samples = points.length / 2 - const per = Math.max(2, Math.round(piece / STEP)) - const cuts: number[] = [] - for (let i = 0; i < samples - 1; i += per) cuts.push(i) - cuts.push(samples - 1) - - return { - points: Float32Array.from(points), - cuts, - depths: cuts.slice(0, -1).map(() => rand()), - } - }) -} - -type Piece = { vine: number; index: number; depth: number } - -/** - * Tangled pen-line vines on a transparent canvas, in the text color by - * default. They grow in from the left the first time they're seen, then - * hold still — nothing runs after that. Redraws on resize and theme change. - */ -export function createTangleFooter( - canvas: HTMLCanvasElement, - initial: TangleFooterOptions = {} -): TangleFooterInstance | null { - const ctx = canvas.getContext("2d") - if (!ctx) return null - - let options: TangleFooterOptions = { ...initial } - const size = { w: 0, h: 0, dpr: 1 } - let vines: Vine[] = [] - let order: Piece[] = [] - /** Cached gap and line paths per piece; the geometry never moves. */ - let paths: { gap: Path2D; line: Path2D }[][] = [] - let thickness = 2 - let grow = 0 - let seen = false - let raf = 0 - let last = 0 - - const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - - /** The stretch of a vine from sample `a` to `b` as a path. */ - function tracePath(vine: Vine, a: number, b: number) { - const p = vine.points - const end = p.length / 2 - 1 - const from = clamp(Math.floor(a), 0, end) - const to = clamp(Math.ceil(b), 0, end) - const path = new Path2D() - path.moveTo(p[from * 2]!, p[from * 2 + 1]!) - for (let i = from + 1; i <= to; i++) path.lineTo(p[i * 2]!, p[i * 2 + 1]!) - return path - } - - const rebuild = () => { - const { w, h } = size - if (w < 2 || h < 2) return - thickness = numberOr(options.thickness, autoThickness(w), 0.5, 24) - vines = buildVines(w, h, options) - paths = vines.map((vine) => - vine.depths.map((_, k) => ({ - gap: tracePath(vine, vine.cuts[k]!, vine.cuts[k + 1]!), - // A sample past each end hides the seam between pieces. - line: tracePath(vine, vine.cuts[k]! - 1, vine.cuts[k + 1]! + 1), - })) - ) - order = vines - .flatMap((vine, v) => - vine.depths.map((depth, index) => ({ vine: v, index, depth })) - ) - .sort((a, b) => a.depth - b.depth) - } - - const resize = () => { - const parent = canvas.parentElement - if (!parent) return - 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 - Object.assign(size, { w, h, dpr }) - canvas.width = Math.max(1, Math.floor(w * dpr)) - canvas.height = Math.max(1, Math.floor(h * dpr)) - rebuild() - wake() - } - - function draw() { - const { w, h, dpr } = size - ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) - ctx!.clearRect(0, 0, w, h) - if (!vines.length || grow <= 0) return - - const eased = 1 - (1 - grow) ** 3 - // Clear space each side of a line where it crosses over another. - const gap = thickness + Math.max(2, thickness * 1.1) * 2 - ctx!.strokeStyle = - options.color?.trim() || getComputedStyle(canvas).color || "#000" - ctx!.lineJoin = "round" - ctx!.lineCap = "round" - - for (const { vine: v, index } of order) { - const vine = vines[v]! - const reach = ((vine.points.length / 2 - 1) * eased) | 0 - const a = vine.cuts[index]! - if (a >= reach) continue - const b = vine.cuts[index + 1]! - // Only the growing tip needs a fresh path. - const path = - b <= reach - ? paths[v]![index]! - : { - gap: tracePath(vine, a, reach), - line: tracePath(vine, a - 1, reach), - } - - ctx!.globalCompositeOperation = "destination-out" - ctx!.lineWidth = gap - ctx!.lineCap = "butt" - ctx!.stroke(path.gap) - ctx!.globalCompositeOperation = "source-over" - ctx!.lineWidth = thickness - ctx!.lineCap = "round" - ctx!.stroke(path.line) - } - } - - function wake() { - if (raf) return - last = performance.now() - raf = requestAnimationFrame(tick) - } - - function tick(now: number) { - raf = 0 - const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) - last = now - if (mqReduce.matches) grow = seen ? 1 : grow - else if (seen && document.visibilityState !== "hidden") - grow = Math.min(1, grow + dt / GROW_S) - draw() - // Frames only while growing; afterwards it's a still drawing. - if (seen && grow < 1) raf = requestAnimationFrame(tick) - } - - const ro = new ResizeObserver(resize) - if (canvas.parentElement) ro.observe(canvas.parentElement) - const io = new IntersectionObserver( - ([entry]) => { - if (!entry?.isIntersecting || seen) return - seen = true - io.disconnect() - wake() - }, - { threshold: 0.25 } - ) - io.observe(canvas) - const onVisibility = () => wake() - document.addEventListener("visibilitychange", onVisibility) - // `currentColor` follows the theme; redraw when it flips. - const mo = new MutationObserver(() => wake()) - mo.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "data-theme", "style"], - }) - const mqDark = window.matchMedia("(prefers-color-scheme: dark)") - mqDark.addEventListener("change", wake) - - resize() - wake() - - return { - setOptions(next) { - const prev = options - options = { ...options, ...next } - if ( - prev.vines !== options.vines || - prev.seed !== options.seed || - prev.thickness !== options.thickness - ) { - rebuild() - // A new tangle grows in again. - if (!mqReduce.matches) grow = 0 - } - wake() - }, - destroy() { - cancelAnimationFrame(raf) - raf = 0 - ro.disconnect() - io.disconnect() - mo.disconnect() - document.removeEventListener("visibilitychange", onVisibility) - mqDark.removeEventListener("change", wake) - }, - } -} diff --git a/registry/tangle-footer/tangle-footer.svelte b/registry/tangle-footer/tangle-footer.svelte deleted file mode 100644 index 4598b58..0000000 --- a/registry/tangle-footer/tangle-footer.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - -
- -
diff --git a/registry/tangle-footer/tangle-footer.tsx b/registry/tangle-footer/tangle-footer.tsx deleted file mode 100644 index 9c944a6..0000000 --- a/registry/tangle-footer/tangle-footer.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client" - -import { useEffect, useRef } from "react" - -import { cn } from "@/lib/utils" - -import { - createTangleFooter, - DEFAULT_SEED, - DEFAULT_VINES, - type TangleFooterInstance, - type TangleFooterOptions, -} from "./tangle-footer-vanilla" - -export { DEFAULT_SEED, DEFAULT_VINES } from "./tangle-footer-vanilla" -export type { - TangleFooterInstance, - TangleFooterOptions, -} from "./tangle-footer-vanilla" - -export type TangleFooterProps = TangleFooterOptions & { - className?: string - /** Footer height in px. Default `320`. */ - height?: number -} - -/** - * A footer of tangled vines — pen lines that curl into loops and cross over - * and under each other. Transparent, in the text color, so it sits on any - * page and follows light and dark. Grows in once when first seen. - */ -export function TangleFooter({ - className, - color, - height = 320, - vines = DEFAULT_VINES, - thickness, - seed = DEFAULT_SEED, -}: TangleFooterProps) { - const canvasRef = useRef(null) - const instanceRef = useRef(null) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - instanceRef.current = createTangleFooter(canvas, { - color, - vines, - thickness, - seed, - }) - 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({ color, vines, thickness, seed }) - }, [color, vines, thickness, seed]) - - return ( -
- -
- ) -} diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index 74c8f92..caca221 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -74,7 +74,7 @@ npm dependencies the registry declares: | Item | React | Svelte | | --- | --- | --- | -| `gooey-color-picker`, `tangle-footer`, `stretchy-footer` | `motion` | none | +| `gooey-color-picker` | `motion` | none | | every other item | none | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -99,7 +99,6 @@ flowchart TD atmo -->|Hair-line explosion| lb[logo-burst] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] - foot -->|Spinning text ribbons| tf[tangle-footer] mark -->|Eyes that track the pointer| orb[live-orb] mark -->|ASCII wordmark, click to scatter| al[ascii-logo] ``` @@ -109,11 +108,10 @@ Disambiguation that agents get wrong: | User says | Use | Not | | --- | --- | --- | | Color picker, swatch, hex, hue, alpha, eyedropper | `gooey-color-picker` | Any shader. Shaders are not controls. | -| Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker`, `tangle-footer` | +| Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | | Fire, embers, heat under a hero | `shader-fire` | `dithered-404` unless the page is a 404 | | 404 that burns under the cursor | `dithered-404` | `shader-fire` | -| Footer of nested spinning sentences | `tangle-footer` | `stretchy-footer` | -| Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `tangle-footer` | +| Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | 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` | @@ -140,8 +138,6 @@ Same shell for `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorSc `Folio` and `StretchyFooter` are the scroller by default. Put the page in `children`. For a real document, set `windowScroll` and mark the tilting or lifting element (`data-folio-page` or `data-stretchy-page`). -`TangleFooter` is a `
`. It is not a background. Place it after the page. - `GooeyColorPicker` is an inline control. It opens upward from the trigger. Give it room (`overflow-visible`); do not clip it in `overflow-hidden`. `LiveOrb` is a fixed square (`size`, default `280`). It does not fill the viewport. The body stays put; only the eyes move. @@ -159,11 +155,10 @@ Exceptions: - Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. -- `TangleFooter` paints `--tangle-ribbon` and `--tangle-text` on itself when `ribbon` / `textColor` are omitted. Pass those props to override. `background` omitted uses `#EFEAE2` / `#121210`. - `PhosphorScore` canvas is transparent in light mode (`LIGHT_BG`) and `#050505` in dark mode (`DARK_BG`). A dark phosphor field is a hard rectangle. Clip the parent (`overflow-hidden rounded-*`) if the square edge matters. Open issue: the docs preview border looks boxed ([#28](https://github.com/radiumcoders/23rd.dev/issues/28)). - `Folio` tilt peaks at an internal 16°. It is not a prop. On a Mac trackpad the lean is easy to miss ([#29](https://github.com/radiumcoders/23rd.dev/issues/29)). Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. -`prefers-reduced-motion: reduce` is honored by the canvas and motion components (still frame, no tilt, no stretch, no tangle spin). Do not add a `reducedMotion` prop. +`prefers-reduced-motion: reduce` is honored by the canvas and motion components (still frame, no tilt, no stretch). Do not add a `reducedMotion` prop. ## Catalog @@ -178,7 +173,6 @@ Categories match `content/docs/components/meta.json`. | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | | `shader-fire` | Shaders | Sparse fire tongues rising from the bottom | React + Svelte | | `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | -| `tangle-footer` | Footers | Five nested SVG text ribbons | React + Svelte | | `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | React + Svelte | | `live-orb` | Characters | Lit sphere; eyes follow the pointer | React + Svelte | | `ascii-logo` | Characters | ASCII wordmark: hover shove, click scatter / fall / gather | React + Svelte | @@ -234,29 +228,6 @@ export function Accent() { } ``` -Ribbon footer (not an overscroll effect): - -```bash -pnpm dlx shadcn@latest add @23rd/tangle-footer -``` - -```tsx -"use client" - -import { TangleFooter } from "@/components/ui/tangle-footer" - -export function SiteFooter() { - return ( - - ) -} -``` - Svelte equivalents use `class`, default imports, and `@23rd/-svelte`. Full prop tables are in [apis.md](apis.md). More assemblies are in [recipes.md](recipes.md). ## Adding a component diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index bb992eb..34fa891 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -194,30 +194,6 @@ type ShaderSkyProps = { `skyFallback(colors, dark)` builds the CSS fallback. Custom `colors` do not swap. -## tangle-footer - -```ts -type TangleFooterProps = { - lines?: string[] // DEFAULT_LINES, five phrases - ribbon?: string - textColor?: string - background?: string // or "transparent" - height?: number // omit → half of measured width - seed?: number // 23 - label?: string // "Site footer" - className?: string -} -``` - -When `ribbon` / `textColor` are omitted the footer sets: - -- `--tangle-ribbon`: `#141414` / dark `#E8E4DC` -- `--tangle-text`: `#F4F0E8` / dark `#161616` - -Stroke and fill read those variables. Field classes: `bg-[#EFEAE2] dark:bg-[#121210]` unless `background` is set. - -`RING_COUNT` is 5. Not a prop. - ## stretchy-footer ```ts diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 2dfce83..28cc556 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -195,34 +195,13 @@ export function Sky() { Pitfalls: `interactive` defaults false (unlike gradient and fire). Custom `colors` stay put. Light `#2478C8 #8ECBF2 #F7FBFF #C5D8EC`. Dark `#9AA3AD #C8CED4 #5C6570 #3F4750`. -## tangle-footer - -Footer. Five nested semicircle ribbons of repeated text. GPU rotation, pauses off-screen. - -Best fit: a site footer that is the visual, with a few phrases. - -Not this: rubber-band overscroll (`stretchy-footer`), a hero background. - -Deps: React needs `motion`. Svelte has none. Key props: `lines`, `ribbon`, `textColor`, `background`, `height`, `seed` (23), `label` (`"Site footer"`). - -```tsx -"use client" -import { TangleFooter } from "@/components/ui/tangle-footer" - -export function Footer() { - return -} -``` - -Pitfalls: default height is half the measured width (upper semicircle, aspect `2 / 1`). A shorter `height` scales the nest down. `background="transparent"` when the parent already paints the stage. Theme colors when props are omitted: ribbon `#141414` / `#E8E4DC`, text `#F4F0E8` / `#161616`, field `#EFEAE2` / `#121210`, via `--tangle-ribbon` and `--tangle-text`. Reduced motion skips the spin. Default lines are the five sentences in `DEFAULT_LINES`. - ## stretchy-footer Footer behavior. Overscroll past the bottom stretches an aurora and lifts the page, then snaps back. Best fit: a Dia-like end of a long page. The component can be the scroller, or an overlay on window / element scroll. -Not this: tilting the page (`folio`), spinning type (`tangle-footer`). +Not this: tilting the page (`folio`). Deps: React needs `motion`. Svelte uses `stretchy-footer-spring-vanilla.ts` and declares no npm deps. Key props: `children`, `scrollRef` / Svelte `scrollEl`, `windowScroll` (false), `contentSelector` (`[data-stretchy-page]`), `maxStretch` (280), `colors`, `stiffness` (380), `damping` (32), `columns` (48), `blur` (14), `glow` (0.22), `label`, `demoId`. diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index 0338775..c93451b 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -99,31 +99,6 @@ export function NotFound() { `dither={false}` keeps the 404 glyph and switches to soft fire. It does not turn the page into `shader-fire`. -## Ribbon footer - -`tangle-footer` after the page, not inside the hero. - -```tsx -"use client" - -import { TangleFooter } from "@/components/ui/tangle-footer" - -export function Footer() { - return ( - - ) -} -``` - -On a colored parent, set `background="transparent"` so the cream/near-black field does not paint a second plate. - ## Rubber overscroll Nested scroller (docs and previews): @@ -248,7 +223,6 @@ export function Brand() { ## Anti-patterns - `ShaderGradient` as a color control, or `GooeyColorPicker` as a page background. -- `TangleFooter` inside a hero to “add motion.” It is a footer with a fixed semicircle height. - `StretchyFooter` and `Folio` both wrapping the same scroll. Pick one owner of the scroll. - Two of `ShaderGradient`, `ShaderFire`, `ShaderSky`, `AsciiFluid` in the same viewport. - Content as a sibling with no `z-10`, so the canvas eats clicks. Set `pointer-events-none` on the canvas wrapper when the field is decorative (`LogoBurst` click-to-replay is the exception). From 9c7ba8dcd08d1c245569865107d2bdfd82a3b6d7 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:48:04 +0530 Subject: [PATCH 19/49] Spring the sidebar marker between pages. The triad is now one persistent marker that springs to the open page, and page names slide aside for it instead of jumping. The old layoutId swap needed the old and new marker mounted together, which the page's view transition didn't reliably allow. --- components/docs-sidebar.tsx | 74 +++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/components/docs-sidebar.tsx b/components/docs-sidebar.tsx index 2a5bbc7..04a10c0 100644 --- a/components/docs-sidebar.tsx +++ b/components/docs-sidebar.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { LayoutGroup, motion, useReducedMotion } from "motion/react" +import { animate, useReducedMotion } from "motion/react" import { DocsSidebarTrigger } from "@/components/docs-sidebar-trigger" import { Logo } from "@/components/logo" @@ -60,21 +60,9 @@ const markSpring = { mass: 0.7, } -/** The open page's marker; slides between items as you navigate. */ -function ActiveTriad() { - const reduce = useReducedMotion() ?? false +/** Room the marker takes before the open page's name. */ +const MARK_GUTTER = "translate-x-[21px]" - return ( - - - - ) -} function PageItem({ node, @@ -92,10 +80,18 @@ function PageItem({ } isActive={active} + data-docs-active={active || undefined} className="h-8 gap-0 rounded-lg bg-transparent px-2 font-normal text-foreground/70 transition-colors duration-150 hover:bg-transparent hover:text-foreground active:bg-transparent data-active:bg-transparent data-active:font-medium data-active:text-foreground data-active:hover:bg-transparent" > - {active ? : null} - {node.name} + {/* The name steps aside for the marker instead of jumping. */} + + {node.name} + ) @@ -157,6 +153,12 @@ function NavItems({ }) } +/** + * One marker for the whole list that springs to the open page. It stays + * mounted across navigations — a shared-layout swap would need both the + * old and new marker at once, which the page's view transition doesn't + * reliably allow. + */ function NavList({ nodes, pathname, @@ -164,12 +166,46 @@ function NavList({ nodes: TreeNode[] pathname: string }) { + const listRef = React.useRef(null) + const markRef = React.useRef(null) + const placed = React.useRef(false) + const reduce = useReducedMotion() ?? false + + React.useLayoutEffect(() => { + const list = listRef.current + const mark = markRef.current + if (!list || !mark) return + const active = list.querySelector("[data-docs-active]") + if (!active) { + mark.style.opacity = "0" + placed.current = false + return + } + const box = list.getBoundingClientRect() + const row = active.getBoundingClientRect() + const y = row.top - box.top + (row.height - mark.offsetHeight) / 2 + mark.style.opacity = "1" + // Land in place on first paint; spring there on every page after. + const jump = !placed.current || reduce + placed.current = true + // Motion places it either way, so it always knows where the marker is. + const flight = animate(mark, { y }, jump ? { duration: 0 } : markSpring) + return () => flight.stop() + }, [pathname, reduce]) + return ( - +
+ + + - +
) } From c0fb3b6af7c3aab0623cac92ec00f39680aa2b31 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:49:04 +0530 Subject: [PATCH 20/49] Round shader previews at the corners. WebGL canvases and backdrop blurs get their own compositor layers, which the stage's rounded overflow clip can miss, so their corners showed square. The stage now clips with a rounded clip-path, and its edge is an inset ring drawn over the content, since the clip would cut an outer one. --- components/component-preview.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/component-preview.tsx b/components/component-preview.tsx index 5f22057..2f8f8d2 100644 --- a/components/component-preview.tsx +++ b/components/component-preview.tsx @@ -45,7 +45,12 @@ export function ComponentPreview({
Date: Sun, 27 Sep 2026 22:53:55 +0530 Subject: [PATCH 21/49] Rethink Shader Gradient as a grainy liquid gradient. The eight hand-placed pastel blobs give way to a palette that flows through domain-warped noise, cycling through up to four colors and blending in OKLab so neighbours stay clean. A new grain prop adds film grain on a CSS-pixel grid, re-rolled at 24 fps. New light and dark palettes, a pointer swirl that eases in and out, and it now pauses off-screen and in hidden tabs and holds still under reduced motion. --- content/docs/components/shader-gradient.mdx | 13 +- public/r/shader-gradient-svelte.json | 2 +- public/r/shader-gradient.json | 2 +- .../shader-gradient/shader-gradient-demo.tsx | 43 +- .../shader-gradient-vanilla.ts | 510 ++++++++++-------- .../shader-gradient/shader-gradient.svelte | 13 +- registry/shader-gradient/shader-gradient.tsx | 23 +- 7 files changed, 342 insertions(+), 264 deletions(-) diff --git a/content/docs/components/shader-gradient.mdx b/content/docs/components/shader-gradient.mdx index 020763e..8835d3a 100644 --- a/content/docs/components/shader-gradient.mdx +++ b/content/docs/components/shader-gradient.mdx @@ -1,11 +1,11 @@ --- title: Shader Gradient -description: A quiet WebGL color wash for landing heroes, empty states, and marketing sections — sits behind UI, follows the theme, optional pointer follow. +description: A grainy liquid gradient for landing heroes, empty states, and marketing sections — the palette flows under a film grain, follows the theme, and swirls around the pointer. --- import { ShaderGradientDemo } from "@/registry/shader-gradient/shader-gradient-demo" -Not a standalone gradient. Use it as atmosphere behind a hero, wait screen, or empty state so the page feels finished without competing with the copy. Soft sky / sage / cream / lavender on paper in light mode; visible dusk hues in dark. Press `d` to toggle. +Atmosphere behind a hero, wait screen, or empty state. Four colors flow through slow, warped noise — liquid bands that never read as a loop — and blend in OKLab, so neighbours stay clean instead of going muddy. A film grain sits on top (`grain`, 0–1), re-rolled at 24 fps like film. Some paper or ink breathes through so it never turns into wall-to-wall color. Peach, butter, sky and lilac on paper in light mode; blue, violet, magenta and coral on ink in dark. Press `d` to toggle. ## Preview @@ -97,11 +97,12 @@ Force a palette if the parent theme should not drive it: | Prop | Type | Default | | --- | --- | --- | -| `colors` | `string[]` | sky / sage / cream / lavender | +| `colors` | `string[]` | theme palette — up to 4, cycled in order | | `speed` | `number` | `0.14` | -| `blur` | `number` | `0.7` | -| `intensity` | `number` | `0.95` | -| `interactive` | `boolean` | `true` | +| `blur` | `number` | `0.7` — 0–1, broader and calmer bands | +| `intensity` | `number` | `0.95` — 0–1, lower shows more paper / ink | +| `grain` | `number` | `0.35` — 0–1 film grain, `0` for none | +| `interactive` | `boolean` | `true` — the flow swirls around the pointer | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | | `className` | `string` | React only | | `class` | `string` | Svelte only | diff --git a/public/r/shader-gradient-svelte.json b/public/r/shader-gradient-svelte.json index 908b301..85c97d9 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", + "content": "\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 e6dc95d..62294d6 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 /** Up to 4 hex colors — order: sky, sage, cream, lavender */\n colors?: string[]\n /** Drift speed. Default 0.14 */\n speed?: number\n /** Wash softness / size 0–1. Default 0.7 */\n blur?: number\n /** Color strength 0–1. Default 0.95 */\n intensity?: number\n /** Follow pointer gently. 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/** Airy hero wash — sky / sage / cream / lavender on paper */\nexport const LIGHT_COLORS = [\"#7CB4E0\", \"#B4D8C4\", \"#EFE4BC\", \"#D2D7EC\"]\n/**\n * Dusk hues that still read on slate — sky / sage / amber / lilac.\n * Bright enough to see, dark enough not to go pastel neon.\n */\nexport const DARK_COLORS = [\"#3A6FA0\", \"#2F6B52\", \"#8A6B32\", \"#4A4D7A\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#FCFBF9\",\n backgroundImage: [\n \"radial-gradient(60% 45% at 60% 20%, #8FC0E4 0%, transparent 70%)\",\n \"radial-gradient(40% 50% at 90% 46%, #8FC0E4 0%, transparent 70%)\",\n \"radial-gradient(30% 40% at 100% 60%, #BEDCCA 0%, transparent 70%)\",\n \"radial-gradient(38% 46% at 4% 56%, #F0E7C4 0%, transparent 70%)\",\n \"radial-gradient(28% 24% at 90% 74%, #F0E7C4 0%, transparent 70%)\",\n \"radial-gradient(40% 38% at 32% 38%, #D5D9EC 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#08090C\",\n backgroundImage: [\n \"radial-gradient(60% 45% at 60% 20%, #3A6FA0 0%, transparent 70%)\",\n \"radial-gradient(40% 50% at 90% 46%, #3A6FA0 0%, transparent 70%)\",\n \"radial-gradient(30% 40% at 100% 60%, #2F6B52 0%, transparent 70%)\",\n \"radial-gradient(38% 46% at 4% 56%, #8A6B32 0%, transparent 70%)\",\n \"radial-gradient(28% 24% at 90% 74%, #8A6B32 0%, transparent 70%)\",\n \"radial-gradient(40% 38% at 32% 38%, #4A4D7A 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\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 float u_blur;\nuniform float u_intensity;\nuniform float u_dark;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform vec2 u_mouse;\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 < 4; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Super-gaussian field — elliptical, rotatable, flat-topped.\n// The flat top is what stops each wash from showing a bright center dot.\nfloat smear(vec2 p, vec2 c, vec2 r, float ang, float plateau) {\n vec2 d = p - c;\n float s = sin(ang);\n float co = cos(ang);\n d = vec2(d.x * co + d.y * s, -d.x * s + d.y * co) / r;\n return exp(-pow(dot(d, d) + 0.0001, plateau));\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / u_resolution.xy;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n\n // Square-ish space so washes keep their shape on any container\n vec2 st = vec2(uv.x * aspect, uv.y);\n\n float t = u_time * u_speed;\n\n // Low-frequency warp — gives the wash a watercolor edge instead of a clean ellipse\n vec2 warp = vec2(\n fbm(st * 1.2 + vec2(t * 0.22, t * 0.16)),\n fbm(st * 1.2 + vec2(-t * 0.18, t * 0.2) + 5.3)\n ) - 0.5;\n st += warp * (0.07 + u_blur * 0.08);\n st -= (u_mouse - 0.5) * 0.05;\n\n float grow = 0.85 + u_blur * 0.4;\n\n // Slow drift — long periods so it never reads as looping\n vec2 d1 = vec2(sin(t * 0.71), cos(t * 0.53)) * 0.045;\n vec2 d2 = vec2(cos(t * 0.61), sin(t * 0.83)) * 0.04;\n vec2 d3 = vec2(sin(t * 0.49), cos(t * 0.77)) * 0.05;\n vec2 d4 = vec2(cos(t * 0.87), sin(t * 0.59)) * 0.045;\n\n // Big sky sweep across the upper right — the dominant mass\n float g1 = smear(st, vec2((0.54 + d1.x) * aspect, 0.84 + d1.y),\n vec2(0.54, 0.30) * grow, -0.5, 1.9);\n // Broad reinforcement, pulled toward the middle of the sweep\n float g2 = smear(st, vec2((0.66 + d2.x) * aspect, 0.72 + d2.y),\n vec2(0.42, 0.30) * grow, -0.45, 1.9);\n // Sky trailing down the right edge\n float g3 = smear(st, vec2((0.92 + d2.y) * aspect, 0.50 + d2.x),\n vec2(0.26, 0.32) * grow, 0.3, 1.7);\n // Sage green hugging the right edge\n float g4 = smear(st, vec2((0.99 + d3.x) * aspect, 0.44 + d3.y),\n vec2(0.28, 0.34) * grow, 0.0, 1.7);\n // Butter cream on the left and low right\n float g5 = smear(st, vec2((0.06 + d4.x) * aspect, 0.44 + d4.y),\n vec2(0.34, 0.38) * grow, 0.4, 1.8);\n float g6 = smear(st, vec2((0.86 + d1.y) * aspect, 0.22 + d1.x),\n vec2(0.28, 0.22) * grow, -0.2, 1.6);\n // Lavender transition between the sky and the cream\n float g7 = smear(st, vec2((0.32 + d3.y) * aspect, 0.60 + d3.x),\n vec2(0.32, 0.28) * grow, 0.2, 1.6);\n // Very wide base tint so the middle never falls back to bare white\n float g8 = smear(st, vec2((0.50 + d4.y) * aspect, 0.40 + d4.x),\n vec2(0.95, 0.80) * grow, 0.0, 1.2);\n\n float w1 = g1 * 1.00;\n float w2 = g2 * 0.45;\n float w3 = g3 * 0.70;\n float w4 = g4 * 0.95;\n float w5 = g5 * 0.95;\n float w6 = g6 * 0.85;\n float w7 = g7 * 0.50;\n float w8 = g8 * 0.30;\n\n float sum = w1 + w2 + w3 + w4 + w5 + w6 + w7 + w8;\n\n // Neutral pale cast — keeps the open middle from going dead white\n vec3 base = mix(u_c4, u_c3, 0.5);\n\n // Weighted average keeps hues clean — no successive-mix muddiness\n vec3 tint =\n (u_c1 * w1 + u_c1 * w2 + u_c1 * w3 + u_c2 * w4 +\n u_c3 * w5 + u_c3 * w6 + u_c4 * w7 + base * w8) /\n max(sum, 0.0001);\n\n // Screen-style coverage so overlaps deepen smoothly instead of clipping\n float cover = 1.0 -\n (1.0 - clamp(w1, 0.0, 1.0)) * (1.0 - clamp(w2, 0.0, 1.0)) *\n (1.0 - clamp(w3, 0.0, 1.0)) * (1.0 - clamp(w4, 0.0, 1.0)) *\n (1.0 - clamp(w5, 0.0, 1.0)) * (1.0 - clamp(w6, 0.0, 1.0)) *\n (1.0 - clamp(w7, 0.0, 1.0)) * (1.0 - clamp(w8, 0.0, 1.0));\n\n // Let the lower left breathe back to bare paper / slate\n float fade = smoothstep(-0.35, 0.55, uv.x * 0.42 + uv.y * 0.72);\n cover *= fade * clamp(u_intensity, 0.0, 1.0);\n\n vec3 col;\n if (u_dark < 0.5) {\n vec3 paper = vec3(0.988, 0.986, 0.982);\n col = mix(paper, tint, clamp(cover, 0.0, 1.0));\n } else {\n // Near-black ink — dusk hues lift off the void without going neon\n vec3 slate = vec3(0.03, 0.032, 0.042);\n float glow = clamp(cover * 0.72, 0.0, 1.0);\n col = mix(slate, tint, glow);\n }\n\n // Dither — kills banding across these very low-contrast ramps\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.005;\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\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.72, 0.86, 0.94]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\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 * Quiet WebGL atmosphere for heroes and empty states — soft-focus color\n * fields behind UI. Theme-aware light / dusk.\n */\nexport function createShaderGradient(\n canvas: HTMLCanvasElement,\n initial: ShaderGradientOptions = {}\n): ShaderGradientInstance | null {\n let options: Required<\n Pick<\n ShaderGradientOptions,\n \"speed\" | \"blur\" | \"intensity\" | \"interactive\" | \"theme\"\n >\n > &\n ShaderGradientOptions = {\n speed: 0.14,\n blur: 0.7,\n intensity: 0.95,\n interactive: true,\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uBlur = gl.getUniformLocation(program, \"u_blur\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uDark = gl.getUniformLocation(program, \"u_dark\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uC4 = gl.getUniformLocation(program, \"u_c4\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 <= 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\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\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.14))\n gl.uniform1f(uBlur, Math.min(1, Math.max(0, options.blur ?? 0.7)))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.95)))\n gl.uniform1f(uDark, dark ? 1 : 0)\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.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderGradientProps = Omit<\n ShaderGradientOptions,\n \"onThemeChange\"\n> & {\n className?: string\n}\n\n/**\n * Quiet WebGL atmosphere for heroes and empty states — soft-focus color\n * fields behind UI. Theme-aware light / dusk.\n */\nexport function ShaderGradient({\n className,\n colors,\n speed = 0.14,\n blur = 0.7,\n intensity = 0.95,\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 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 interactive,\n theme,\n onThemeChange: setIsDark,\n })\n }, [colors, speed, blur, intensity, interactive, theme])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \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 = [\"#2A3BD1\", \"#7A3FD6\", \"#C43A9A\", \"#F26D5B\"]\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 float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t));\n lab = mix(u_base, lab, u_intensity * mix(0.45, 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 */\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uBlur = u(\"u_blur\")\n const uIntensity = u(\"u_intensity\")\n const uGrain = u(\"u_grain\")\n const uGrainSeed = u(\"u_grainSeed\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\"), u(\"u_c3\")]\n const uBase = u(\"u_base\")\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 last = 0\n let clock = 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf) 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 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 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 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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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 \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/shader-gradient.tsx" } diff --git a/registry/shader-gradient/shader-gradient-demo.tsx b/registry/shader-gradient/shader-gradient-demo.tsx index 89cf6a7..f33e7f5 100644 --- a/registry/shader-gradient/shader-gradient-demo.tsx +++ b/registry/shader-gradient/shader-gradient-demo.tsx @@ -14,6 +14,10 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { DARK_COLORS, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_COLORS, ShaderGradient, } from "@/registry/shader-gradient/shader-gradient" @@ -38,9 +42,10 @@ export function ShaderGradientDemo() { const defaults = useMemo( () => ({ - speed: 0.14, - blur: 0.7, - intensity: 0.95, + speed: DEFAULT_SPEED, + blur: DEFAULT_BLUR, + intensity: DEFAULT_INTENSITY, + grain: DEFAULT_GRAIN, interactive: true, colors: palette, }), @@ -73,6 +78,7 @@ export function ShaderGradientDemo() { speed={props.speed} blur={props.blur} intensity={props.intensity} + grain={props.grain} interactive={props.interactive} colors={useAutoTheme ? undefined : props.colors} /> @@ -88,8 +94,8 @@ export function ShaderGradientDemo() { A first screen that already feels finished

- Headline and a primary action sit on the wash. The shader stays - in the back. + Headline and a primary action sit on the wash. The shader stays in + the back.

@@ -110,10 +116,12 @@ export function ShaderGradientDemo() { onReset={resetProps} component="ShaderGradient" snippetProps={{ - speed: props.speed, - blur: props.blur, - intensity: props.intensity, - interactive: props.interactive, + speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, + blur: props.blur === DEFAULT_BLUR ? undefined : props.blur, + intensity: + props.intensity === DEFAULT_INTENSITY ? undefined : props.intensity, + grain: props.grain === DEFAULT_GRAIN ? undefined : props.grain, + interactive: props.interactive ? undefined : false, colors: useAutoTheme ? undefined : props.colors, }} > @@ -122,9 +130,10 @@ export function ShaderGradientDemo() { colors={props.colors} palettes={[ palette, - ["#E07A5F", "#F2CC8F", "#81B29A", "#3D405B"], - ["#0D1B2A", "#1B263B", "#415A77", "#778DA9"], - ["#F72585", "#7209B7", "#3A0CA3", "#4CC9F0"], + // Each cycles back to its first color, so neighbours stay close. + ["#FF6B6B", "#FFB36B", "#FFE08A", "#FF8FB1"], + ["#0B3D91", "#1F7A8C", "#6CC4A1", "#2E5EAA"], + ["#F72585", "#B5179E", "#7209B7", "#4361EE"], ]} onChange={(colors) => updateProp("colors", colors)} /> @@ -152,9 +161,17 @@ export function ShaderGradientDemo() { step={0.05} onChange={(v) => updateProp("intensity", v)} /> + updateProp("grain", v)} + /> updateProp("interactive", v)} /> diff --git a/registry/shader-gradient/shader-gradient-vanilla.ts b/registry/shader-gradient/shader-gradient-vanilla.ts index 7c2bfdd..6df15ab 100644 --- a/registry/shader-gradient/shader-gradient-vanilla.ts +++ b/registry/shader-gradient/shader-gradient-vanilla.ts @@ -1,15 +1,20 @@ export type ShaderGradientTheme = "light" | "dark" | "auto" export type ShaderGradientOptions = { - /** Up to 4 hex colors — order: sky, sage, cream, lavender */ + /** + * Up to 4 colors, any CSS hex. The flow cycles through them in order and + * back to the first, so neighbours should sit well together. + */ colors?: string[] - /** Drift speed. Default 0.14 */ + /** Flow speed. Default `0.14`. */ speed?: number - /** Wash softness / size 0–1. Default 0.7 */ + /** Softness, 0–1: higher is broader, calmer bands. Default `0.7`. */ blur?: number - /** Color strength 0–1. Default 0.95 */ + /** Color strength, 0–1; lower lets the paper / ink show through. Default `0.95`. */ intensity?: number - /** Follow pointer gently. Default true */ + /** Film grain, 0–1. `0` turns it off. Default `0.35`. */ + grain?: number + /** The flow swirls gently around the pointer. Default `true`. */ interactive?: boolean /** * Palette mode. Default `auto` follows shadcn / next-themes @@ -25,37 +30,41 @@ export type ShaderGradientInstance = { destroy: () => void } -/** Airy hero wash — sky / sage / cream / lavender on paper */ -export const LIGHT_COLORS = ["#7CB4E0", "#B4D8C4", "#EFE4BC", "#D2D7EC"] +/** Peach, butter, sky and lilac, flowing over warm paper. */ +export const LIGHT_COLORS = ["#F7A48B", "#F9D78E", "#9FCBF0", "#BBA9EE"] /** - * Dusk hues that still read on slate — sky / sage / amber / lilac. - * Bright enough to see, dark enough not to go pastel neon. + * Blue, violet, magenta and coral over near-black ink — a narrow hue arc, + * so every blend on the cycle stays rich instead of passing through grey. */ -export const DARK_COLORS = ["#3A6FA0", "#2F6B52", "#8A6B32", "#4A4D7A"] - -export const LIGHT_FALLBACK = { - backgroundColor: "#FCFBF9", - backgroundImage: [ - "radial-gradient(60% 45% at 60% 20%, #8FC0E4 0%, transparent 70%)", - "radial-gradient(40% 50% at 90% 46%, #8FC0E4 0%, transparent 70%)", - "radial-gradient(30% 40% at 100% 60%, #BEDCCA 0%, transparent 70%)", - "radial-gradient(38% 46% at 4% 56%, #F0E7C4 0%, transparent 70%)", - "radial-gradient(28% 24% at 90% 74%, #F0E7C4 0%, transparent 70%)", - "radial-gradient(40% 38% at 32% 38%, #D5D9EC 0%, transparent 70%)", - ].join(", "), -} as const - -export const DARK_FALLBACK = { - backgroundColor: "#08090C", - backgroundImage: [ - "radial-gradient(60% 45% at 60% 20%, #3A6FA0 0%, transparent 70%)", - "radial-gradient(40% 50% at 90% 46%, #3A6FA0 0%, transparent 70%)", - "radial-gradient(30% 40% at 100% 60%, #2F6B52 0%, transparent 70%)", - "radial-gradient(38% 46% at 4% 56%, #8A6B32 0%, transparent 70%)", - "radial-gradient(28% 24% at 90% 74%, #8A6B32 0%, transparent 70%)", - "radial-gradient(40% 38% at 32% 38%, #4A4D7A 0%, transparent 70%)", - ].join(", "), -} as const +export const DARK_COLORS = ["#2A3BD1", "#7A3FD6", "#C43A9A", "#F26D5B"] +const LIGHT_BASE = "#FBF8F4" +const DARK_BASE = "#07080B" + +export const DEFAULT_SPEED = 0.14 +export const DEFAULT_BLUR = 0.7 +export const DEFAULT_INTENSITY = 0.95 +export const DEFAULT_GRAIN = 0.35 + +/** Film grain for the CSS fallback — the same idea, as an SVG turbulence tile. */ +const 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")` + +function fallback(colors: string[], base: string) { + const [a, b, c, d] = colors + return { + backgroundColor: base, + backgroundImage: [ + GRAIN_TILE, + `radial-gradient(70% 60% at 18% 22%, ${a} 0%, transparent 70%)`, + `radial-gradient(60% 60% at 82% 30%, ${b} 0%, transparent 70%)`, + `radial-gradient(70% 60% at 70% 88%, ${c} 0%, transparent 70%)`, + `radial-gradient(55% 55% at 12% 86%, ${d} 0%, transparent 70%)`, + ].join(", "), + } as const +} + +/** Shown before the first frame and wherever WebGL isn't available. */ +export const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE) +export const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE) const VERT = ` attribute vec2 a_position; @@ -69,36 +78,43 @@ precision highp float; uniform vec2 u_resolution; uniform float u_time; -uniform float u_speed; uniform float u_blur; uniform float u_intensity; -uniform float u_dark; +uniform float u_grain; +uniform float u_grainSeed; +uniform float u_pixel; +uniform float u_pointer; +uniform vec2 u_mouse; +// Palette stops and the paper / ink underneath, in OKLab. +uniform vec3 u_c0; uniform vec3 u_c1; uniform vec3 u_c2; uniform vec3 u_c3; -uniform vec3 u_c4; -uniform vec2 u_mouse; +uniform vec3 u_base; +// Dave Hoskins' hash — no sin(), so it stays stable on mobile GPUs. float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); - float a = hash(i); - float b = hash(i + vec2(1.0, 0.0)); - float c = hash(i + vec2(0.0, 1.0)); - float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); - return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; + return mix( + mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); } float fbm(vec2 p) { float v = 0.0; float a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6); - for (int i = 0; i < 4; i++) { + for (int i = 0; i < 3; i++) { v += a * noise(p); p = m * p; a *= 0.5; @@ -106,119 +122,103 @@ float fbm(vec2 p) { return v; } -// Super-gaussian field — elliptical, rotatable, flat-topped. -// The flat top is what stops each wash from showing a bright center dot. -float smear(vec2 p, vec2 c, vec2 r, float ang, float plateau) { - vec2 d = p - c; - float s = sin(ang); - float co = cos(ang); - d = vec2(d.x * co + d.y * s, -d.x * s + d.y * co) / r; - return exp(-pow(dot(d, d) + 0.0001, plateau)); +// Weight of the stop at 'at' on a cyclic 0–1 track: a smoothed hat, so any +// two neighbouring weights always sum to one. +float stop(float v, float at) { + float d = abs(v - at); + d = min(d, 1.0 - d); + return smoothstep(0.0, 1.0, 1.0 - clamp(d * 4.0, 0.0, 1.0)); } -void main() { - vec2 uv = gl_FragCoord.xy / u_resolution.xy; - float aspect = u_resolution.x / max(u_resolution.y, 1.0); +vec3 palette(float v) { + return u_c0 * stop(v, 0.0) + u_c1 * stop(v, 0.25) + + u_c2 * stop(v, 0.5) + u_c3 * stop(v, 0.75); +} - // Square-ish space so washes keep their shape on any container - vec2 st = vec2(uv.x * aspect, uv.y); - - float t = u_time * u_speed; - - // Low-frequency warp — gives the wash a watercolor edge instead of a clean ellipse - vec2 warp = vec2( - fbm(st * 1.2 + vec2(t * 0.22, t * 0.16)), - fbm(st * 1.2 + vec2(-t * 0.18, t * 0.2) + 5.3) - ) - 0.5; - st += warp * (0.07 + u_blur * 0.08); - st -= (u_mouse - 0.5) * 0.05; - - float grow = 0.85 + u_blur * 0.4; - - // Slow drift — long periods so it never reads as looping - vec2 d1 = vec2(sin(t * 0.71), cos(t * 0.53)) * 0.045; - vec2 d2 = vec2(cos(t * 0.61), sin(t * 0.83)) * 0.04; - vec2 d3 = vec2(sin(t * 0.49), cos(t * 0.77)) * 0.05; - vec2 d4 = vec2(cos(t * 0.87), sin(t * 0.59)) * 0.045; - - // Big sky sweep across the upper right — the dominant mass - float g1 = smear(st, vec2((0.54 + d1.x) * aspect, 0.84 + d1.y), - vec2(0.54, 0.30) * grow, -0.5, 1.9); - // Broad reinforcement, pulled toward the middle of the sweep - float g2 = smear(st, vec2((0.66 + d2.x) * aspect, 0.72 + d2.y), - vec2(0.42, 0.30) * grow, -0.45, 1.9); - // Sky trailing down the right edge - float g3 = smear(st, vec2((0.92 + d2.y) * aspect, 0.50 + d2.x), - vec2(0.26, 0.32) * grow, 0.3, 1.7); - // Sage green hugging the right edge - float g4 = smear(st, vec2((0.99 + d3.x) * aspect, 0.44 + d3.y), - vec2(0.28, 0.34) * grow, 0.0, 1.7); - // Butter cream on the left and low right - float g5 = smear(st, vec2((0.06 + d4.x) * aspect, 0.44 + d4.y), - vec2(0.34, 0.38) * grow, 0.4, 1.8); - float g6 = smear(st, vec2((0.86 + d1.y) * aspect, 0.22 + d1.x), - vec2(0.28, 0.22) * grow, -0.2, 1.6); - // Lavender transition between the sky and the cream - float g7 = smear(st, vec2((0.32 + d3.y) * aspect, 0.60 + d3.x), - vec2(0.32, 0.28) * grow, 0.2, 1.6); - // Very wide base tint so the middle never falls back to bare white - float g8 = smear(st, vec2((0.50 + d4.y) * aspect, 0.40 + d4.x), - vec2(0.95, 0.80) * grow, 0.0, 1.2); - - float w1 = g1 * 1.00; - float w2 = g2 * 0.45; - float w3 = g3 * 0.70; - float w4 = g4 * 0.95; - float w5 = g5 * 0.95; - float w6 = g6 * 0.85; - float w7 = g7 * 0.50; - float w8 = g8 * 0.30; - - float sum = w1 + w2 + w3 + w4 + w5 + w6 + w7 + w8; - - // Neutral pale cast — keeps the open middle from going dead white - vec3 base = mix(u_c4, u_c3, 0.5); - - // Weighted average keeps hues clean — no successive-mix muddiness - vec3 tint = - (u_c1 * w1 + u_c1 * w2 + u_c1 * w3 + u_c2 * w4 + - u_c3 * w5 + u_c3 * w6 + u_c4 * w7 + base * w8) / - max(sum, 0.0001); - - // Screen-style coverage so overlaps deepen smoothly instead of clipping - float cover = 1.0 - - (1.0 - clamp(w1, 0.0, 1.0)) * (1.0 - clamp(w2, 0.0, 1.0)) * - (1.0 - clamp(w3, 0.0, 1.0)) * (1.0 - clamp(w4, 0.0, 1.0)) * - (1.0 - clamp(w5, 0.0, 1.0)) * (1.0 - clamp(w6, 0.0, 1.0)) * - (1.0 - clamp(w7, 0.0, 1.0)) * (1.0 - clamp(w8, 0.0, 1.0)); - - // Let the lower left breathe back to bare paper / slate - float fade = smoothstep(-0.35, 0.55, uv.x * 0.42 + uv.y * 0.72); - cover *= fade * clamp(u_intensity, 0.0, 1.0); - - vec3 col; - if (u_dark < 0.5) { - vec3 paper = vec3(0.988, 0.986, 0.982); - col = mix(paper, tint, clamp(cover, 0.0, 1.0)); - } else { - // Near-black ink — dusk hues lift off the void without going neon - vec3 slate = vec3(0.03, 0.032, 0.042); - float glow = clamp(cover * 0.72, 0.0, 1.0); - col = mix(slate, tint, glow); - } +vec3 oklabToLinear(vec3 c) { + float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z; + float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z; + float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.z; + l = l * l * l; + m = m * m * m; + s = s * s * s; + return vec3( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s + ); +} + +vec3 linearToSrgb(vec3 c) { + c = clamp(c, 0.0, 1.0); + vec3 lo = c * 12.92; + vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; + return mix(lo, hi, step(vec3(0.0031308), c)); +} - // Dither — kills banding across these very low-contrast ramps - col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.005; +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution; + float aspect = u_resolution.x / max(u_resolution.y, 1.0); + float scale = mix(2.0, 0.8, u_blur); + vec2 p = vec2(uv.x * aspect, uv.y) * scale; + float t = u_time; + + // A soft swirl around the pointer. + vec2 d = p - vec2(u_mouse.x * aspect, u_mouse.y) * scale; + p += vec2(-d.y, d.x) * exp(-dot(d, d) * 1.4) * 0.4 * u_pointer; + + // Domain warping: noise fed back through itself twice gives slow, + // liquid bands that never read as a loop. + vec2 q = vec2( + fbm(p + vec2(0.0, 0.12 * t)), + fbm(p + vec2(5.2, 1.3) - 0.1 * t) + ); + vec2 r = vec2( + fbm(p + 2.2 * q + vec2(1.7, 9.2) + 0.07 * t), + fbm(p + 2.2 * q + vec2(8.3, 2.8) - 0.06 * t) + ); + float f = fbm(p + 2.0 * r); + + // Where on the palette this pixel sits. The track is cyclic, so wrapping + // past the last color flows back into the first without a seam. + float v = fract(f * 2.4 + 0.35 * r.x + 0.02 * t); + vec3 lab = palette(v); + + // Some of the paper / ink breathes through, so it isn't wall-to-wall color. + float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t)); + lab = mix(u_base, lab, u_intensity * mix(0.45, 1.0, air)); + + vec3 col = linearToSrgb(oklabToLinear(lab)); + + // Film grain on a CSS-pixel grid: two hashes averaged for a softer, + // more photographic distribution than flat white noise. + vec2 cell = floor(gl_FragCoord.xy / u_pixel); + float g = (hash(cell + u_grainSeed) + hash(cell.yx - u_grainSeed * 1.7)) * 0.5; + col += (g - 0.5) * u_grain * 0.2; + // Always a hair of dither, so the smooth ramps never band. + col += (hash(cell * 1.31 + u_grainSeed) - 0.5) * 0.004; gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); } ` function isDev() { - return typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + 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] { +/** Hex → OKLab, so the shader can blend without going muddy. */ +function hexToOklab(hex: string): [number, number, number] { const h = hex.replace("#", "").trim() const full = h.length === 3 @@ -228,8 +228,20 @@ function hexToRgb(hex: string): [number, number, number] { .join("") : h.padEnd(6, "0").slice(0, 6) const n = Number.parseInt(full, 16) - if (Number.isNaN(n)) return [0.72, 0.86, 0.94] - return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + const srgb = Number.isNaN(n) + ? [0.5, 0.5, 0.5] + : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + const [r, g, b] = srgb.map((c) => + c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + ) as [number, number, number] + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ] } /** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ @@ -269,31 +281,26 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { } /** - * Quiet WebGL atmosphere for heroes and empty states — soft-focus color - * fields behind UI. Theme-aware light / dusk. + * A grainy liquid gradient for heroes and empty states. The palette flows + * through slow domain-warped noise, blends in OKLab so neighbours never go + * muddy, and carries a film grain. Theme-aware; pauses off-screen and in + * hidden tabs; holds a still frame under `prefers-reduced-motion`. */ export function createShaderGradient( canvas: HTMLCanvasElement, initial: ShaderGradientOptions = {} ): ShaderGradientInstance | null { - let options: Required< - Pick< - ShaderGradientOptions, - "speed" | "blur" | "intensity" | "interactive" | "theme" - > - > & - ShaderGradientOptions = { - speed: 0.14, - blur: 0.7, - intensity: 0.95, + let options: ShaderGradientOptions = { interactive: true, theme: "auto", ...initial, } const mouse = { x: 0.5, y: 0.5 } - const targetMouse = { x: 0.5, y: 0.5 } - let reduce = false + const target = { x: 0.5, y: 0.5 } + // Pointer pull eases in and out, so leaving the hero never snaps. + let pull = 0 + let pullTarget = 0 let dark = resolveDark(options.theme ?? "auto") options.onThemeChange?.(dark) @@ -348,46 +355,110 @@ export function createShaderGradient( gl.enableVertexAttribArray(loc) gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) - const uResolution = gl.getUniformLocation(program, "u_resolution") - const uTime = gl.getUniformLocation(program, "u_time") - const uSpeed = gl.getUniformLocation(program, "u_speed") - const uBlur = gl.getUniformLocation(program, "u_blur") - const uIntensity = gl.getUniformLocation(program, "u_intensity") - const uDark = gl.getUniformLocation(program, "u_dark") - const uC1 = gl.getUniformLocation(program, "u_c1") - const uC2 = gl.getUniformLocation(program, "u_c2") - const uC3 = gl.getUniformLocation(program, "u_c3") - const uC4 = gl.getUniformLocation(program, "u_c4") - const uMouse = gl.getUniformLocation(program, "u_mouse") - - let raf = 0 - let running = true - const start = performance.now() - let frozenTime = 0 - + const u = (name: string) => gl.getUniformLocation(program, name) + const uResolution = u("u_resolution") + const uTime = u("u_time") + const uBlur = u("u_blur") + const uIntensity = u("u_intensity") + const uGrain = u("u_grain") + const uGrainSeed = u("u_grainSeed") + const uPixel = u("u_pixel") + const uPointer = u("u_pointer") + const uMouse = u("u_mouse") + const uStops = [u("u_c0"), u("u_c1"), u("u_c2"), u("u_c3")] + const uBase = u("u_base") + + // 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 const resize = () => { const parent = canvas.parentElement if (!parent) return - 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)) + 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)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` gl.viewport(0, 0, canvas.width, canvas.height) + wake() } - resize() - const ro = new ResizeObserver(resize) - if (canvas.parentElement) ro.observe(canvas.parentElement) + let colorsKey = "" + const syncColors = () => { + const source = options.colors?.length + ? options.colors + : dark + ? DARK_COLORS + : LIGHT_COLORS + const key = `${source.join()}|${dark}` + if (key === colorsKey) return + colorsKey = key + uStops.forEach((loc, i) => { + gl.uniform3fv(loc, hexToOklab(source[i % source.length]!)) + }) + gl.uniform3fv(uBase, hexToOklab(dark ? DARK_BASE : LIGHT_BASE)) + } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - const onReduce = () => { - reduce = mqReduce.matches + let onScreen = true + let raf = 0 + let last = 0 + let clock = 0 + + const moving = () => + onScreen && !mqReduce.matches && document.visibilityState !== "hidden" + + function wake() { + if (raf) return + last = performance.now() + raf = requestAnimationFrame(tick) + } + + // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside. + const tick = (now: number) => { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const live = moving() + if (live) clock += dt + + mouse.x += (target.x - mouse.x) * 0.05 + mouse.y += (target.y - mouse.y) * 0.05 + pull += (pullTarget - pull) * 0.04 + + syncColors() + const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4) + gl.uniform2f(uResolution, canvas.width, canvas.height) + gl.uniform1f(uTime, clock * speed * 4) + gl.uniform1f(uBlur, numberOr(options.blur, DEFAULT_BLUR, 0, 1)) + gl.uniform1f( + uIntensity, + numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1) + ) + gl.uniform1f(uGrain, numberOr(options.grain, DEFAULT_GRAIN, 0, 1)) + // Grain re-rolls at 24 fps, like film, and holds still when paused. + gl.uniform1f(uGrainSeed, (Math.floor(clock * 24) % 997) * 13.7) + gl.uniform1f(uPixel, pixel) + gl.uniform1f(uPointer, options.interactive === false ? 0 : pull) + gl.uniform2f(uMouse, mouse.x, mouse.y) + gl.drawArrays(gl.TRIANGLES, 0, 6) + + if (live) raf = requestAnimationFrame(tick) } - onReduce() + + 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 = () => { @@ -395,6 +466,7 @@ export function createShaderGradient( if (next === dark) return dark = next options.onThemeChange?.(dark) + wake() } const mo = new MutationObserver(syncTheme) mo.observe(document.documentElement, { @@ -405,66 +477,36 @@ export function createShaderGradient( mqDark.addEventListener("change", syncTheme) const onMove = (e: PointerEvent) => { - if (!options.interactive) return - const parent = canvas.parentElement - if (!parent) return - const rect = parent.getBoundingClientRect() + if (options.interactive === false) return + const rect = canvas.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return - targetMouse.x = (e.clientX - rect.left) / rect.width - targetMouse.y = 1 - (e.clientY - rect.top) / rect.height - } - window.addEventListener("pointermove", onMove, { passive: true }) - - const tick = (now: number) => { - if (!running) return - - if (reduce) { - if (frozenTime === 0) frozenTime = (now - start) / 1000 - } else { - frozenTime = 0 + 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 + pullTarget = inside ? 1 : 0 + if (inside) { + target.x = x + target.y = y } - - const time = reduce ? frozenTime : (now - start) / 1000 - const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS) - const palette = [0, 1, 2, 3].map((i) => - hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!) - ) - - mouse.x += (targetMouse.x - mouse.x) * 0.03 - mouse.y += (targetMouse.y - mouse.y) * 0.03 - - gl.uniform2f(uResolution, canvas.width, canvas.height) - gl.uniform1f(uTime, time) - gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.14)) - gl.uniform1f(uBlur, Math.min(1, Math.max(0, options.blur ?? 0.7))) - gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.95))) - gl.uniform1f(uDark, dark ? 1 : 0) - gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2]) - gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2]) - gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2]) - gl.uniform3f(uC4, palette[3]![0], palette[3]![1], palette[3]![2]) - gl.uniform2f( - uMouse, - options.interactive ? mouse.x : 0.5, - options.interactive ? mouse.y : 0.5 - ) - - gl.drawArrays(gl.TRIANGLES, 0, 6) - raf = requestAnimationFrame(tick) } + window.addEventListener("pointermove", onMove, { passive: true }) - raf = requestAnimationFrame(tick) + resize() + wake() return { setOptions(next) { options = { ...options, ...next } syncTheme() + wake() }, destroy() { - running = false 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) diff --git a/registry/shader-gradient/shader-gradient.svelte b/registry/shader-gradient/shader-gradient.svelte index 1ea7c3e..e67e912 100644 --- a/registry/shader-gradient/shader-gradient.svelte +++ b/registry/shader-gradient/shader-gradient.svelte @@ -6,6 +6,10 @@ import { createShaderGradient, DARK_FALLBACK, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, type ShaderGradientInstance, @@ -23,9 +27,10 @@ let { class: className = "", colors, - speed = 0.14, - blur = 0.7, - intensity = 0.95, + speed = DEFAULT_SPEED, + blur = DEFAULT_BLUR, + intensity = DEFAULT_INTENSITY, + grain = DEFAULT_GRAIN, interactive = true, theme = "auto", }: Props = $props() @@ -58,6 +63,7 @@ speed, blur, intensity, + grain, interactive, theme, onThemeChange: (dark) => { @@ -82,6 +88,7 @@ speed, blur, intensity, + grain, interactive, theme, }) diff --git a/registry/shader-gradient/shader-gradient.tsx b/registry/shader-gradient/shader-gradient.tsx index fbb01eb..a153246 100644 --- a/registry/shader-gradient/shader-gradient.tsx +++ b/registry/shader-gradient/shader-gradient.tsx @@ -7,6 +7,10 @@ import { cn } from "@/lib/utils" import { createShaderGradient, DARK_FALLBACK, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, type ShaderGradientInstance, @@ -16,6 +20,10 @@ import { export { DARK_COLORS, DARK_FALLBACK, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_COLORS, LIGHT_FALLBACK, } from "./shader-gradient-vanilla" @@ -33,15 +41,16 @@ export type ShaderGradientProps = Omit< } /** - * Quiet WebGL atmosphere for heroes and empty states — soft-focus color - * fields behind UI. Theme-aware light / dusk. + * A grainy liquid gradient for heroes and empty states — the palette flows + * through slow warped noise under a film grain. Theme-aware. */ export function ShaderGradient({ className, colors, - speed = 0.14, - blur = 0.7, - intensity = 0.95, + speed = DEFAULT_SPEED, + blur = DEFAULT_BLUR, + intensity = DEFAULT_INTENSITY, + grain = DEFAULT_GRAIN, interactive = true, theme = "auto", }: ShaderGradientProps) { @@ -73,6 +82,7 @@ export function ShaderGradient({ speed, blur, intensity, + grain, interactive, theme, onThemeChange: setIsDark, @@ -91,11 +101,12 @@ export function ShaderGradient({ speed, blur, intensity, + grain, interactive, theme, onThemeChange: setIsDark, }) - }, [colors, speed, blur, intensity, interactive, theme]) + }, [colors, speed, blur, intensity, grain, interactive, theme]) const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK From 5e3982ccccbc82382aa0e174531f760bc0d6ad45 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Sun, 27 Sep 2026 22:55:48 +0530 Subject: [PATCH 22/49] Toggle the hero copy on the gradient and fire previews. A Copy switch hides the headline and buttons so the shader can be seen on its own, matching the one Shader Sky already has. --- registry/shader-fire/shader-fire-demo.tsx | 63 +++++++++++-------- .../shader-gradient/shader-gradient-demo.tsx | 63 +++++++++++-------- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/registry/shader-fire/shader-fire-demo.tsx b/registry/shader-fire/shader-fire-demo.tsx index 741b485..48e12f2 100644 --- a/registry/shader-fire/shader-fire-demo.tsx +++ b/registry/shader-fire/shader-fire-demo.tsx @@ -42,6 +42,7 @@ export function ShaderFireDemo() { intensity: 0.55, height: 0.45, interactive: true, + overlay: true, dither: false, pixelSize: 1, colors: palette, @@ -79,32 +80,36 @@ export function ShaderFireDemo() { pixelSize={props.pixelSize} colors={useAutoTheme ? undefined : props.colors} /> -
-
-

- Atmosphere -

-

- Heat under the headline -

-

- Sparse tongues rise from the bottom. Copy stays in the quiet - middle; the fire stays a wash. -

-
- - -
-
+ {props.overlay ? ( + <> +
+
+

+ Atmosphere +

+

+ Heat under the headline +

+

+ Sparse tongues rise from the bottom. Copy stays in the quiet + middle; the fire stays a wash. +

+
+ + +
+
+ + ) : null}
@@ -157,6 +162,12 @@ export function ShaderFireDemo() { step={0.05} onChange={(v) => updateProp("height", v)} /> + updateProp("overlay", v)} + /> -
-
-

- Landing -

-

- A first screen that already feels finished -

-

- Headline and a primary action sit on the wash. The shader stays in - the back. -

-
- - -
-
+ {props.overlay ? ( + <> +
+
+

+ Landing +

+

+ A first screen that already feels finished +

+

+ Headline and a primary action sit on the wash. The shader + stays in the back. +

+
+ + +
+
+ + ) : null}
@@ -169,6 +174,12 @@ export function ShaderGradientDemo() { step={0.05} onChange={(v) => updateProp("grain", v)} /> + updateProp("overlay", v)} + /> Date: Mon, 28 Sep 2026 12:31:55 +0530 Subject: [PATCH 23/49] Make Phosphor Score transparent in dark mode too. Dark mode painted a solid #050505 slab that read as a hard square box against the preview and any page. The canvas now clears in both themes and fades its edges by erasing, so it sits on whatever the parent paints; the wrappers stop painting a background of their own. Closes #28 --- .cursor/skills/23rd/SKILL.md | 2 +- .cursor/skills/23rd/apis.md | 2 +- .cursor/skills/23rd/components.md | 2 +- .cursor/skills/23rd/recipes.md | 1 - content/docs/components/phosphor-score.mdx | 4 ++-- public/r/phosphor-score-svelte.json | 2 +- public/r/phosphor-score.json | 2 +- .../phosphor-score/phosphor-score-vanilla.ts | 20 +++++++++---------- registry/phosphor-score/phosphor-score.svelte | 12 ----------- registry/phosphor-score/phosphor-score.tsx | 17 +--------------- skills/23rd/SKILL.md | 2 +- skills/23rd/apis.md | 2 +- skills/23rd/components.md | 2 +- skills/23rd/recipes.md | 1 - 14 files changed, 20 insertions(+), 51 deletions(-) diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index caca221..f08c1b9 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -155,7 +155,7 @@ Exceptions: - Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. -- `PhosphorScore` canvas is transparent in light mode (`LIGHT_BG`) and `#050505` in dark mode (`DARK_BG`). A dark phosphor field is a hard rectangle. Clip the parent (`overflow-hidden rounded-*`) if the square edge matters. Open issue: the docs preview border looks boxed ([#28](https://github.com/radiumcoders/23rd.dev/issues/28)). +- `PhosphorScore` canvas is transparent in both themes; the parent paints the background. - `Folio` tilt peaks at an internal 16°. It is not a prop. On a Mac trackpad the lean is easy to miss ([#29](https://github.com/radiumcoders/23rd.dev/issues/29)). Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. `prefers-reduced-motion: reduce` is honored by the canvas and motion components (still frame, no tilt, no stretch). Do not add a `reducedMotion` prop. diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 34fa891..167c924 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -83,7 +83,7 @@ type PhosphorScoreProps = { } ``` -`DARK_COLOR` `#4DFF6A`, `LIGHT_COLOR` `#147A3A`, `DARK_BG` `#050505`, `LIGHT_BG` `"transparent"`. A set `color` replaces ink only. Background still follows dark vs light. +`DARK_COLOR` `#4DFF6A`, `LIGHT_COLOR` `#147A3A`, `DARK_BG` and `LIGHT_BG` are both `"transparent"`. A set `color` replaces ink only. ## radiant-lines diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index 28cc556..a82b783 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -61,7 +61,7 @@ export function Score() { } ``` -Pitfalls: omit `color` so theme can swap (dark `#4DFF6A` on `#050505`, light `#147A3A` on a transparent canvas). Dark mode paints a solid `#050505` rectangle — clip the parent. Issue [#28](https://github.com/radiumcoders/23rd.dev/issues/28) is that square edge. The docs sentence “Press d to toggle” is not implemented on the component or the demo. Do not add a `d` shortcut. +Pitfalls: omit `color` so theme can swap (dark `#4DFF6A`, light `#147A3A`). The canvas is transparent in both themes; the parent paints the background. Issue [#28](https://github.com/radiumcoders/23rd.dev/issues/28) is that square edge. The docs sentence “Press d to toggle” is not implemented on the component or the demo. Do not add a `d` shortcut. ## radiant-lines diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index c93451b..01b8143 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -226,6 +226,5 @@ export function Brand() { - `StretchyFooter` and `Folio` both wrapping the same scroll. Pick one owner of the scroll. - Two of `ShaderGradient`, `ShaderFire`, `ShaderSky`, `AsciiFluid` in the same viewport. - Content as a sibling with no `z-10`, so the canvas eats clicks. Set `pointer-events-none` on the canvas wrapper when the field is decorative (`LogoBurst` click-to-replay is the exception). -- `PhosphorScore` on a light card in dark mode without clipping. The canvas fills `#050505`. - Assuming `theme="auto"` recolors a shader after you passed `colors`. It will not. - Adding `variant` to anything but `LiveOrb`. diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx index 23627f1..27ef6bf 100644 --- a/content/docs/components/phosphor-score.mdx +++ b/content/docs/components/phosphor-score.mdx @@ -5,7 +5,7 @@ description: Vertical CRT sheet music — notes slide down a grand staff, bloom import { PhosphorScoreDemo } from "@/registry/phosphor-score/phosphor-score-demo" -A falling score, not a piano roll. A grand staff of beamed runs, chords, slurs and dynamics scrolls down to a playhead: each note brightens as it arrives, blooms and flares as it plays, then fades with a phosphor afterglow. Default `theme="auto"` follows the site — phosphor on black, forest ink on the page background. `glow` is 0–100 (default `50`) and stays a tight halo on the note. Change **Seed** to crossfade to a new score. Press `d` to toggle the theme. +A falling score, not a piano roll. A grand staff of beamed runs, chords, slurs and dynamics scrolls down to a playhead: each note brightens as it arrives, blooms and flares as it plays, then fades with a phosphor afterglow. Default `theme="auto"` follows the site — phosphor green in dark, forest ink in light, both on the page background. `glow` is 0–100 (default `50`) and stays a tight halo on the note. Change **Seed** to crossfade to a new score. Press `d` to toggle the theme. ## Preview @@ -105,4 +105,4 @@ Change the viewing angle. The plane turns about the playhead in perspective, lik | `className` | `string` | React only | | `class` | `string` | Svelte only | -Omit `color` and `theme="auto"` picks phosphor green on dark, forest ink on light. The canvas is transparent in light mode so the parent background shows through. +Omit `color` and `theme="auto"` picks phosphor green on dark, forest ink on light. The canvas is transparent in both themes, so it sits on whatever background the parent paints. diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index e9358c2..3bbc7c0 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 c1ef102..ad5f331 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\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-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 if (dark) {\n ctx!.fillStyle = DARK_BG\n ctx!.fillRect(0, 0, w, h)\n } else {\n ctx!.clearRect(0, 0, w, h)\n }\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 const solid = dark ? \"rgba(5,5,5,1)\" : \"rgba(0,0,0,1)\"\n const clear = dark ? \"rgba(5,5,5,0)\" : \"rgba(0,0,0,0)\"\n ctx!.globalCompositeOperation = dark ? \"source-over\" : \"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 * Background before the first frame, so dark mode never flashes light.\n * `#050505` is `DARK_BG`; Tailwind needs the literal.\n */\nconst SURFACE: Record = {\n auto: \"bg-background dark:bg-[#050505]\",\n dark: \"bg-[#050505]\",\n light: \"bg-background\",\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 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", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/registry/phosphor-score/phosphor-score-vanilla.ts b/registry/phosphor-score/phosphor-score-vanilla.ts index 4572af4..06e9bce 100644 --- a/registry/phosphor-score/phosphor-score-vanilla.ts +++ b/registry/phosphor-score/phosphor-score-vanilla.ts @@ -51,8 +51,9 @@ export const DARK_COLOR = "#4DFF6A" export const LIGHT_COLOR = "#147A3A" /** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */ export const DEFAULT_COLOR = DARK_COLOR -export const DARK_BG = "#050505" -/** Light mode is transparent — the parent `bg-background` shows through. */ +/** Transparent in both themes — the parent's background shows through. */ +export const DARK_BG = "transparent" +/** Transparent in both themes — the parent's background shows through. */ export const LIGHT_BG = "transparent" export const DEFAULT_GLOW = 50 export const DEFAULT_SPEED = 1.35 @@ -687,12 +688,7 @@ export function createPhosphorScore( ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) ctx!.globalCompositeOperation = "source-over" ctx!.globalAlpha = 1 - if (dark) { - ctx!.fillStyle = DARK_BG - ctx!.fillRect(0, 0, w, h) - } else { - ctx!.clearRect(0, 0, w, h) - } + ctx!.clearRect(0, 0, w, h) if (w < 2 || h < 2) return view = makeView( @@ -1143,9 +1139,11 @@ export function createPhosphorScore( /** Top and bottom fade into the background — in the canvas, so it costs nothing and blurs nothing. */ function drawEdgeFade(w: number, h: number) { const f = h * EDGE_FADE - const solid = dark ? "rgba(5,5,5,1)" : "rgba(0,0,0,1)" - const clear = dark ? "rgba(5,5,5,0)" : "rgba(0,0,0,0)" - ctx!.globalCompositeOperation = dark ? "source-over" : "destination-out" + // Erase toward the edges rather than paint over them, so the fade + // lands on whatever background the parent has. + const solid = "rgba(0,0,0,1)" + const clear = "rgba(0,0,0,0)" + ctx!.globalCompositeOperation = "destination-out" const top = ctx!.createLinearGradient(0, 0, 0, f) top.addColorStop(0, solid) top.addColorStop(1, clear) diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index 7b2cdaf..472bc17 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -11,7 +11,6 @@ DEFAULT_SPEED, type PhosphorScoreInstance, type PhosphorScoreOptions, - type PhosphorScoreTheme, } from "./phosphor-score-vanilla" interface Props extends Omit { @@ -32,16 +31,6 @@ theme = "auto", }: Props = $props() - /** - * Background before the first frame, so dark mode never flashes light. - * `#050505` is `DARK_BG`; Tailwind needs the literal. - */ - const SURFACE: Record = { - auto: "bg-background dark:bg-[#050505]", - dark: "bg-[#050505]", - light: "bg-background", - } - let canvas: HTMLCanvasElement | undefined = $state() let instance: PhosphorScoreInstance | null = null @@ -87,7 +76,6 @@ aria-label="Falling phosphor sheet music" class={[ "absolute inset-0 overflow-hidden", - SURFACE[theme] ?? SURFACE.auto, className, ] .filter(Boolean) diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index cdd5f88..807796a 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -12,7 +12,6 @@ import { DEFAULT_SPEED, type PhosphorScoreInstance, type PhosphorScoreOptions, - type PhosphorScoreTheme, } from "./phosphor-score-vanilla" export { @@ -38,16 +37,6 @@ export type PhosphorScoreProps = Omit & { className?: string } -/** - * Background before the first frame, so dark mode never flashes light. - * `#050505` is `DARK_BG`; Tailwind needs the literal. - */ -const SURFACE: Record = { - auto: "bg-background dark:bg-[#050505]", - dark: "bg-[#050505]", - light: "bg-background", -} - /** * Vertical phosphor sheet music — a grand staff scrolls down to a playhead, * each note blooms and flares as it plays, then fades with an afterglow. @@ -123,11 +112,7 @@ export function PhosphorScore({ data-slot="phosphor-score" role="img" aria-label="Falling phosphor sheet music" - className={cn( - "absolute inset-0 overflow-hidden", - SURFACE[theme] ?? SURFACE.auto, - className - )} + className={cn("absolute inset-0 overflow-hidden", className)} > diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index caca221..f08c1b9 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -155,7 +155,7 @@ Exceptions: - Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. -- `PhosphorScore` canvas is transparent in light mode (`LIGHT_BG`) and `#050505` in dark mode (`DARK_BG`). A dark phosphor field is a hard rectangle. Clip the parent (`overflow-hidden rounded-*`) if the square edge matters. Open issue: the docs preview border looks boxed ([#28](https://github.com/radiumcoders/23rd.dev/issues/28)). +- `PhosphorScore` canvas is transparent in both themes; the parent paints the background. - `Folio` tilt peaks at an internal 16°. It is not a prop. On a Mac trackpad the lean is easy to miss ([#29](https://github.com/radiumcoders/23rd.dev/issues/29)). Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. `prefers-reduced-motion: reduce` is honored by the canvas and motion components (still frame, no tilt, no stretch). Do not add a `reducedMotion` prop. diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 34fa891..167c924 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -83,7 +83,7 @@ type PhosphorScoreProps = { } ``` -`DARK_COLOR` `#4DFF6A`, `LIGHT_COLOR` `#147A3A`, `DARK_BG` `#050505`, `LIGHT_BG` `"transparent"`. A set `color` replaces ink only. Background still follows dark vs light. +`DARK_COLOR` `#4DFF6A`, `LIGHT_COLOR` `#147A3A`, `DARK_BG` and `LIGHT_BG` are both `"transparent"`. A set `color` replaces ink only. ## radiant-lines diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 28cc556..a82b783 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -61,7 +61,7 @@ export function Score() { } ``` -Pitfalls: omit `color` so theme can swap (dark `#4DFF6A` on `#050505`, light `#147A3A` on a transparent canvas). Dark mode paints a solid `#050505` rectangle — clip the parent. Issue [#28](https://github.com/radiumcoders/23rd.dev/issues/28) is that square edge. The docs sentence “Press d to toggle” is not implemented on the component or the demo. Do not add a `d` shortcut. +Pitfalls: omit `color` so theme can swap (dark `#4DFF6A`, light `#147A3A`). The canvas is transparent in both themes; the parent paints the background. Issue [#28](https://github.com/radiumcoders/23rd.dev/issues/28) is that square edge. The docs sentence “Press d to toggle” is not implemented on the component or the demo. Do not add a `d` shortcut. ## radiant-lines diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index c93451b..01b8143 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -226,6 +226,5 @@ export function Brand() { - `StretchyFooter` and `Folio` both wrapping the same scroll. Pick one owner of the scroll. - Two of `ShaderGradient`, `ShaderFire`, `ShaderSky`, `AsciiFluid` in the same viewport. - Content as a sibling with no `z-10`, so the canvas eats clicks. Set `pointer-events-none` on the canvas wrapper when the field is decorative (`LogoBurst` click-to-replay is the exception). -- `PhosphorScore` on a light card in dark mode without clipping. The canvas fills `#050505`. - Assuming `theme="auto"` recolors a shader after you passed `colors`. It will not. - Adding `variant` to anything but `LiveOrb`. From fde05a849877c80a49f672751e382708226270d9 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 12:33:57 +0530 Subject: [PATCH 24/49] Lean Folio by wheel speed so trackpads tilt too. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lean was read from each wheel event's delta. A mouse notch is one big delta, but a Mac trackpad sends a stream of small ones, so a fast swipe peaked near 2° against a mouse's 11°. The lean now follows a smoothed wheel speed (px/ms): a fast swipe reaches about 12°, level with a flick, while slow reading scrolls stay subtle. Line- and page-mode deltas are converted to px. Closes #29 --- .cursor/skills/23rd/SKILL.md | 2 +- .cursor/skills/23rd/components.md | 2 +- .cursor/skills/23rd/recipes.md | 2 +- public/r/folio-svelte.json | 2 +- public/r/folio.json | 2 +- registry/folio/folio-vanilla.ts | 31 +++++++++++++++++++++++++------ skills/23rd/SKILL.md | 2 +- skills/23rd/components.md | 2 +- skills/23rd/recipes.md | 2 +- 9 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index f08c1b9..ede6b3f 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -156,7 +156,7 @@ Exceptions: - Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. - `PhosphorScore` canvas is transparent in both themes; the parent paints the background. -- `Folio` tilt peaks at an internal 16°. It is not a prop. On a Mac trackpad the lean is easy to miss ([#29](https://github.com/radiumcoders/23rd.dev/issues/29)). Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. +- `Folio` tilt peaks at an internal 16°. It is not a prop. The lean follows wheel speed, so trackpads and mice lean alike. Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. `prefers-reduced-motion: reduce` is honored by the canvas and motion components (still frame, no tilt, no stretch). Do not add a `reducedMotion` prop. diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index a82b783..504ab6c 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -309,7 +309,7 @@ Section. The sheet tips one way on scroll down, the other on scroll up, blur on Best fit: a long editorial page that should feel like one sheet. -Not this: overscroll aurora (`stretchy-footer`). Do not use it when the user needs an obvious effect on a trackpad — issue [#29](https://github.com/radiumcoders/23rd.dev/issues/29) says the lean is barely visible on Mac. The docs “show effect” button calls `playFolioDemo`; that is a preview, not a stronger public tilt. +Not this: overscroll aurora (`stretchy-footer`). The lean follows wheel speed, so a fast trackpad swipe leans about as far as a mouse flick; slow reading-speed scrolls stay subtle. The docs “show effect” button calls `playFolioDemo`; that is a preview, not a stronger public tilt. Deps: none. Key props: `children`, `blur` (4), `perspective` (1000, floor 1000), `returnMs` (520), `windowScroll` (false), `contentSelector` (`[data-folio-page]`), `label` (`"Tilting page"`), `demoId`. diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index 01b8143..11ff369 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -158,7 +158,7 @@ export function Essay() { } ``` -If the effect is too subtle, say so. Do not invent a `tilt` prop. Issue #29 tracks the Mac trackpad case. `playFolioDemo({ target, holdMs })` only runs the preview on a matching `demoId`. +If the effect is too subtle, say so. Do not invent a `tilt` prop. `playFolioDemo({ target, holdMs })` only runs the preview on a matching `demoId`. Window mode: diff --git a/public/r/folio-svelte.json b/public/r/folio-svelte.json index 7be38c8..97d175f 100644 --- a/public/r/folio-svelte.json +++ b/public/r/folio-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/folio/folio.svelte", - "content": "\n\n\n\n{#if !windowScroll}\n \n \n {@render children?.()}\n \n \n{/if}\n", + "content": "\n\n\n\n{#if !windowScroll}\n \n \n {@render children?.()}\n \n \n{/if}\n", "type": "registry:file", "target": "src/lib/components/ui/folio.svelte" } diff --git a/public/r/folio.json b/public/r/folio.json index 3cde265..b4bc4cc 100644 --- a/public/r/folio.json +++ b/public/r/folio.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/folio/folio.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const FOLIO_PLAY = \"folio:play\"\nexport const FOLIO_IDLE_MS = 200\n\nexport type FolioPlayDetail = {\n /** Only instances whose `demoId` matches will play. */\n target?: string\n /** Nested scroller to drive. Omit for `window`. */\n scrollRoot?: HTMLElement | null\n /** How long to hold the lean before springing back, in ms. Default `420`. */\n holdMs?: number\n}\n\nexport type FolioRuntimeOptions = {\n /** Peak blur in px at full tilt. Default `4`. */\n blur?: number\n /** CSS perspective distance in px. Default `1000`. Floor `1000`. */\n perspective?: number\n /**\n * How long the lean takes to come back after the bottom, in ms.\n * Default `520`.\n */\n returnMs?: number\n}\n\nexport type FolioInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nconst TILT_PEAK = 16\nconst DEFAULT_BLUR = 4\nconst DEFAULT_PERSPECTIVE = 1000\nconst MIN_PERSPECTIVE = 1000\nconst DEFAULT_RETURN_MS = 520\nconst VEL_REF = 2.4\nconst WHEEL_REF = 110\nconst WHEEL_SCROLL_LOCK_MS = 64\nconst END_SLACK_PX = 8\nconst SPRING_IN = { stiffness: 72, damping: 22 }\nconst SPRING_OUT = { stiffness: 42, damping: 18 }\n\ntype Spring = {\n set: (value: number) => void\n get: () => number\n destroy: () => void\n}\n\nfunction createSpring(onChange: (value: number) => void): Spring {\n let current = 0\n let target = 0\n let velocity = 0\n let raf = 0\n let lastTime = 0\n let stiffness = SPRING_IN.stiffness\n let damping = SPRING_IN.damping\n\n function integrate(dt: number) {\n const accel = -stiffness * (current - target) - damping * velocity\n velocity += accel * dt\n current += velocity * dt\n }\n\n function tick(now: number) {\n if (!lastTime) lastTime = now\n let remaining = Math.min((now - lastTime) / 1000, 0.048)\n lastTime = now\n const step = 1 / 60\n while (remaining > 0) {\n integrate(Math.min(step, remaining))\n remaining -= step\n }\n const settled =\n Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03\n if (settled) {\n current = target\n velocity = 0\n raf = 0\n lastTime = 0\n onChange(current)\n return\n }\n onChange(current)\n raf = requestAnimationFrame(tick)\n }\n\n function start() {\n if (!raf) {\n lastTime = 0\n raf = requestAnimationFrame(tick)\n }\n }\n\n return {\n set(value) {\n target = value\n if (value === 0) {\n stiffness = SPRING_OUT.stiffness\n damping = SPRING_OUT.damping\n } else {\n stiffness = SPRING_IN.stiffness\n damping = SPRING_IN.damping\n }\n if (Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03) {\n current = target\n velocity = 0\n onChange(current)\n return\n }\n start()\n },\n get() {\n return current\n },\n destroy() {\n if (raf) cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\nfunction isWindow(scroller: HTMLElement | Window): scroller is Window {\n return scroller === window\n}\n\nfunction readScrollTop(scroller: HTMLElement | Window) {\n return isWindow(scroller)\n ? window.scrollY || document.documentElement.scrollTop\n : scroller.scrollTop\n}\n\nfunction maxScroll(scroller: HTMLElement | Window) {\n if (isWindow(scroller)) {\n return Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n }\n return Math.max(0, scroller.scrollHeight - scroller.clientHeight)\n}\n\nfunction clamp01(n: number) {\n return Math.max(0, Math.min(1, n))\n}\n\nfunction clampSigned(n: number) {\n return Math.max(-1, Math.min(1, n))\n}\n\nfunction remainingScroll(scroller: HTMLElement | Window) {\n return Math.max(0, maxScroll(scroller) - readScrollTop(scroller))\n}\n\n/** `1` in the body of the page, `0` at the bottom so the down-lean dies. */\nfunction endFade(scroller: HTMLElement | Window) {\n const left = remainingScroll(scroller)\n if (left <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((left - END_SLACK_PX) / zone)\n}\n\n/** `1` in the body of the page, `0` at the top so the up-lean dies. */\nfunction startFade(scroller: HTMLElement | Window) {\n const top = readScrollTop(scroller)\n if (top <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((top - END_SLACK_PX) / zone)\n}\n\n/** Map scroll delta to a signed -1…1 lean. Positive = scrolling down. */\nexport function leanFromDelta(deltaPx: number, ref = WHEEL_REF) {\n return clampSigned(deltaPx / ref)\n}\n\n/** Slow travel sits near 5°, a flick reaches 16°. */\nfunction tiltAngle(impulse: number) {\n const mag = Math.min(1, Math.abs(impulse))\n const deg = mag * mag * (TILT_PEAK - 5) + mag * 5\n return deg * Math.sign(impulse || 1)\n}\n\nfunction hingeY(\n scroller: HTMLElement | Window,\n plane: HTMLElement,\n tilt: number\n) {\n const vh = viewHeightOf(scroller)\n const localViewTop = readScrollTop(scroller) - plane.offsetTop\n if (Math.abs(tilt) < 0.35) return localViewTop + vh * 0.5\n return tilt > 0 ? localViewTop + vh : localViewTop\n}\n\nexport function applyFolioFrame(\n plane: HTMLElement,\n tilt: number,\n options: FolioRuntimeOptions = {},\n reducedMotion = false,\n originY?: number\n) {\n const blur = Math.max(0, options.blur ?? DEFAULT_BLUR)\n const perspective = Math.max(\n MIN_PERSPECTIVE,\n options.perspective ?? DEFAULT_PERSPECTIVE\n )\n const angle = reducedMotion ? 0 : tilt\n const amount = Math.min(1, Math.abs(angle) / TILT_PEAK)\n const blurPx = reducedMotion ? 0 : amount * blur\n\n plane.style.transformOrigin =\n originY !== undefined\n ? `50% ${originY.toFixed(1)}px`\n : Math.abs(angle) < 0.35\n ? \"50% 50%\"\n : angle > 0\n ? \"50% 100%\"\n : \"50% 0%\"\n plane.style.backfaceVisibility = \"hidden\"\n plane.style.transform = `perspective(${perspective}px) rotateX(${angle}deg)`\n plane.style.filter = \"none\"\n\n const veil = ensureBlurVeil(plane)\n if (blurPx < 0.08) {\n veil.style.cssText = \"display:none\"\n } else {\n const ramp =\n angle >= 0\n ? \"linear-gradient(to top, transparent 0%, transparent 32%, black 100%)\"\n : \"linear-gradient(to bottom, transparent 0%, transparent 32%, black 100%)\"\n veil.style.cssText = [\n \"pointer-events:none\",\n \"position:absolute\",\n \"inset:0\",\n \"z-index:2\",\n `backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `-webkit-backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `mask-image:${ramp}`,\n `-webkit-mask-image:${ramp}`,\n ].join(\";\")\n }\n plane.style.willChange = Math.abs(angle) > 0.08 ? \"transform\" : \"auto\"\n}\n\nfunction animateScroll(\n scroller: HTMLElement | Window,\n to: number,\n durationMs: number,\n signal: AbortSignal\n) {\n const from = readScrollTop(scroller)\n if (durationMs <= 0) {\n if (isWindow(scroller)) window.scrollTo({ top: to })\n else scroller.scrollTop = to\n return Promise.resolve()\n }\n\n const start = performance.now()\n return new Promise((resolve) => {\n const step = (now: number) => {\n if (signal.aborted) {\n resolve()\n return\n }\n const t = Math.min(1, (now - start) / durationMs)\n const e = t * t * t * (t * (t * 6 - 15) + 10)\n const y = from + (to - from) * e\n if (isWindow(scroller)) window.scrollTo({ top: y })\n else scroller.scrollTop = y\n if (t < 1) requestAnimationFrame(step)\n else resolve()\n }\n requestAnimationFrame(step)\n })\n}\n\n/** Lean the page while scrolling down, then spring flat. */\nexport async function playFolioDemo(detail: FolioPlayDetail = {}) {\n if (typeof window === \"undefined\") return\n window.dispatchEvent(\n new CustomEvent(FOLIO_PLAY, { detail })\n )\n const hold = detail.holdMs ?? 720\n await new Promise((r) => window.setTimeout(r, hold + 1800))\n}\n\nfunction viewHeightOf(scroller: HTMLElement | Window) {\n return isWindow(scroller) ? window.innerHeight : scroller.clientHeight\n}\n\nfunction ensureBlurVeil(plane: HTMLElement) {\n let veil = plane.querySelector(\"[data-slot='folio-blur-veil']\")\n if (!veil) {\n if (getComputedStyle(plane).position === \"static\") {\n plane.style.position = \"relative\"\n }\n veil = document.createElement(\"div\")\n veil.dataset.slot = \"folio-blur-veil\"\n veil.setAttribute(\"aria-hidden\", \"true\")\n plane.appendChild(veil)\n }\n return veil\n}\n\nexport function createFolio(options: {\n plane: HTMLElement\n scroller: HTMLElement | Window\n demoId?: string\n blur?: number\n perspective?: number\n returnMs?: number\n}): FolioInstance {\n const runtime: FolioRuntimeOptions = {\n blur: options.blur,\n perspective: options.perspective,\n returnMs: options.returnMs,\n }\n\n let reduced = false\n let playing = false\n let lastTop = readScrollTop(options.scroller)\n let lastTime = performance.now()\n let lastWheelAt = 0\n let impulse = 0\n let gate = 1\n let gateFrom = 1\n let gateTarget = 1\n let gateT0 = 0\n let gateRaf = 0\n let idleTimer: ReturnType | null = null\n const playAbort = { current: new AbortController() }\n\n const paint = (tilt: number) => {\n applyFolioFrame(\n options.plane,\n tilt,\n runtime,\n reduced,\n hingeY(options.scroller, options.plane, tilt)\n )\n }\n\n const spring = createSpring(paint)\n\n const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const applyReduce = () => {\n reduced = media.matches && !playing\n if (reduced) {\n impulse = 0\n gate = 1\n spring.set(0)\n paint(0)\n }\n }\n applyReduce()\n media.addEventListener(\"change\", applyReduce)\n\n function returnMs() {\n return Math.max(0, runtime.returnMs ?? DEFAULT_RETURN_MS)\n }\n\n function sampleGate(now = performance.now()) {\n const ms = returnMs()\n if (ms <= 0) {\n gate = gateTarget\n return gate\n }\n const t = Math.min(1, (now - gateT0) / ms)\n const e = t * t * (3 - 2 * t)\n gate = gateFrom + (gateTarget - gateFrom) * e\n return gate\n }\n\n function goGate(to: number) {\n if (to === 0) {\n gate = 0\n gateFrom = 0\n gateTarget = 0\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateTarget !== 1) {\n gateFrom = sampleGate()\n gateTarget = 1\n gateT0 = performance.now()\n }\n if (gate >= 0.999) {\n gate = 1\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateRaf) return\n const tick = (now: number) => {\n sampleGate(now)\n applyLean()\n if (gateTarget === 1 && gate < 0.999) {\n gateRaf = requestAnimationFrame(tick)\n } else {\n if (gateTarget === 1) gate = 1\n gateRaf = 0\n }\n }\n gateRaf = requestAnimationFrame(tick)\n }\n\n function applyLean(leavingEdge = false) {\n if (reduced) {\n spring.set(0)\n return\n }\n if (playing) {\n spring.set(tiltAngle(impulse || 1))\n return\n }\n const down = impulse >= 0\n const fade = leavingEdge\n ? 1\n : down\n ? endFade(options.scroller)\n : startFade(options.scroller)\n if (fade <= 0.02) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n goGate(1)\n const g = sampleGate()\n spring.set(tiltAngle(impulse * fade * g))\n }\n\n function lean(amount: number, leavingEdge = false) {\n if (reduced) return\n const next = clampSigned(amount)\n const nextSign = Math.sign(next)\n const prevSign = Math.sign(impulse)\n if (nextSign !== 0 && prevSign !== 0 && nextSign !== prevSign) {\n impulse = next\n } else {\n const dir = nextSign || prevSign || 1\n const mag = Math.max(\n Math.abs(next),\n Math.abs(impulse) * 0.62 + Math.abs(next) * 0.38\n )\n impulse = mag * dir\n }\n applyLean(leavingEdge)\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n impulse = 0\n if (!playing) spring.set(0)\n }, FOLIO_IDLE_MS)\n }\n\n const onWheel = (event: Event) => {\n if (playing) return\n const dy = (event as WheelEvent).deltaY\n if (!dy) return\n lastWheelAt = performance.now()\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n const leavingEnd = atEnd && dy < 0\n const leavingStart = atStart && dy > 0\n if ((atEnd && !leavingEnd) || (atStart && !leavingStart)) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n lean(leanFromDelta(dy), leavingEnd || leavingStart)\n }\n\n const onScroll = () => {\n const now = performance.now()\n const top = readScrollTop(options.scroller)\n const dt = Math.max(8, now - lastTime)\n const vel = (top - lastTop) / dt\n lastTop = top\n lastTime = now\n if (playing) {\n applyLean()\n return\n }\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n if (atEnd && vel >= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (atStart && vel <= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (now - lastWheelAt < WHEEL_SCROLL_LOCK_MS) {\n applyLean(atEnd || atStart)\n return\n }\n lean(clampSigned(vel / VEL_REF), (atEnd && vel < 0) || (atStart && vel > 0))\n }\n\n const wheelTarget: EventTarget = isWindow(options.scroller)\n ? window\n : options.scroller\n wheelTarget.addEventListener(\"wheel\", onWheel, { passive: true })\n options.scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n const onResize = () => paint(spring.get())\n window.addEventListener(\"resize\", onResize)\n\n const onPlay = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n if (options.demoId) {\n if (!detail.target || detail.target !== options.demoId) return\n } else if (detail.target) {\n return\n }\n\n playAbort.current.abort()\n playAbort.current = new AbortController()\n const signal = playAbort.current.signal\n const hold = detail.holdMs ?? 720\n const scroller = detail.scrollRoot ?? options.scroller\n playing = true\n if (idleTimer) clearTimeout(idleTimer)\n applyReduce()\n\n void (async () => {\n const max = maxScroll(scroller)\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n const limit = Math.max(0, max - zone - END_SLACK_PX)\n let start = readScrollTop(scroller)\n if (start >= limit - 24) {\n impulse = 0\n gate = 1\n gateTarget = 1\n spring.set(0)\n if (isWindow(scroller)) window.scrollTo({ top: 0 })\n else scroller.scrollTop = 0\n lastTop = 0\n start = 0\n }\n const dest = Math.min(\n limit,\n start + Math.max(560, viewHeightOf(scroller) * 1.35)\n )\n impulse = 1\n gate = 1\n gateTarget = 1\n spring.set(TILT_PEAK)\n await animateScroll(scroller, dest, Math.max(1400, hold + 700), signal)\n if (signal.aborted) {\n playing = false\n applyReduce()\n return\n }\n playing = false\n applyReduce()\n impulse = 0\n spring.set(0)\n })()\n }\n\n window.addEventListener(FOLIO_PLAY, onPlay)\n paint(0)\n\n return {\n setOptions(next) {\n if (next.blur !== undefined) runtime.blur = next.blur\n if (next.perspective !== undefined) runtime.perspective = next.perspective\n if (next.returnMs !== undefined) runtime.returnMs = next.returnMs\n paint(spring.get())\n },\n destroy() {\n playing = false\n playAbort.current.abort()\n if (idleTimer) clearTimeout(idleTimer)\n if (gateRaf) cancelAnimationFrame(gateRaf)\n spring.destroy()\n options.plane.querySelector(\"[data-slot='folio-blur-veil']\")?.remove()\n media.removeEventListener(\"change\", applyReduce)\n wheelTarget.removeEventListener(\"wheel\", onWheel)\n options.scroller.removeEventListener(\"scroll\", onScroll)\n window.removeEventListener(\"resize\", onResize)\n window.removeEventListener(FOLIO_PLAY, onPlay)\n options.plane.style.transform = \"\"\n options.plane.style.filter = \"\"\n options.plane.style.willChange = \"\"\n options.plane.style.backfaceVisibility = \"\"\n options.plane.style.transformOrigin = \"\"\n },\n }\n}\n\nexport type FolioProps = FolioRuntimeOptions & {\n className?: string\n /** Page content that leans while you scroll. */\n children?: ReactNode\n /**\n * Bind to the window and tilt `contentSelector` instead of wrapping\n * children. Default `false` — this component *is* the scroller.\n */\n windowScroll?: boolean\n /**\n * Element to tilt when `windowScroll` is set.\n * Default `[data-folio-page]`.\n */\n contentSelector?: string\n /** Accessible name for the tilting page. */\n label?: string\n /**\n * Optional id for docs demos. `playFolioDemo({ target })` only\n * animates instances whose `demoId` matches.\n */\n demoId?: string\n}\n\n/**\n * The whole page leans in perspective while you scroll, with a matching\n * blur. When scroll stops, it springs to flat.\n */\nexport function Folio({\n className,\n children,\n blur = 4,\n perspective = 1000,\n returnMs = 520,\n windowScroll = false,\n contentSelector = \"[data-folio-page]\",\n label = \"Tilting page\",\n demoId,\n}: FolioProps) {\n const scrollerRef = useRef(null)\n const planeRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const scroller = windowScroll ? window : scrollerRef.current\n const plane = windowScroll\n ? document.querySelector(contentSelector)\n : planeRef.current\n if (!scroller || !plane) return\n\n instanceRef.current = createFolio({\n plane,\n scroller,\n demoId,\n blur,\n perspective,\n returnMs,\n })\n\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; bind once per scroller mode.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, contentSelector, demoId])\n\n useEffect(() => {\n instanceRef.current?.setOptions({ blur, perspective, returnMs })\n }, [blur, perspective, returnMs])\n\n if (windowScroll) return null\n\n return (\n \n \n {children}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const FOLIO_PLAY = \"folio:play\"\nexport const FOLIO_IDLE_MS = 200\n\nexport type FolioPlayDetail = {\n /** Only instances whose `demoId` matches will play. */\n target?: string\n /** Nested scroller to drive. Omit for `window`. */\n scrollRoot?: HTMLElement | null\n /** How long to hold the lean before springing back, in ms. Default `420`. */\n holdMs?: number\n}\n\nexport type FolioRuntimeOptions = {\n /** Peak blur in px at full tilt. Default `4`. */\n blur?: number\n /** CSS perspective distance in px. Default `1000`. Floor `1000`. */\n perspective?: number\n /**\n * How long the lean takes to come back after the bottom, in ms.\n * Default `520`.\n */\n returnMs?: number\n}\n\nexport type FolioInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nconst TILT_PEAK = 16\nconst DEFAULT_BLUR = 4\nconst DEFAULT_PERSPECTIVE = 1000\nconst MIN_PERSPECTIVE = 1000\nconst DEFAULT_RETURN_MS = 520\nconst VEL_REF = 2.4\nconst WHEEL_REF = 110\n/**\n * Wheel speed, in px/ms, that leans all the way. Trackpads send a stream\n * of small deltas, so a lean read per event barely moved; speed reads a\n * fast swipe and a fast mouse flick the same.\n */\nconst WHEEL_VEL_REF = 1.1\n/** A wheel gap longer than this starts a fresh gesture. */\nconst WHEEL_GESTURE_GAP_MS = 150\nconst WHEEL_SCROLL_LOCK_MS = 64\nconst END_SLACK_PX = 8\nconst SPRING_IN = { stiffness: 72, damping: 22 }\nconst SPRING_OUT = { stiffness: 42, damping: 18 }\n\ntype Spring = {\n set: (value: number) => void\n get: () => number\n destroy: () => void\n}\n\nfunction createSpring(onChange: (value: number) => void): Spring {\n let current = 0\n let target = 0\n let velocity = 0\n let raf = 0\n let lastTime = 0\n let stiffness = SPRING_IN.stiffness\n let damping = SPRING_IN.damping\n\n function integrate(dt: number) {\n const accel = -stiffness * (current - target) - damping * velocity\n velocity += accel * dt\n current += velocity * dt\n }\n\n function tick(now: number) {\n if (!lastTime) lastTime = now\n let remaining = Math.min((now - lastTime) / 1000, 0.048)\n lastTime = now\n const step = 1 / 60\n while (remaining > 0) {\n integrate(Math.min(step, remaining))\n remaining -= step\n }\n const settled =\n Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03\n if (settled) {\n current = target\n velocity = 0\n raf = 0\n lastTime = 0\n onChange(current)\n return\n }\n onChange(current)\n raf = requestAnimationFrame(tick)\n }\n\n function start() {\n if (!raf) {\n lastTime = 0\n raf = requestAnimationFrame(tick)\n }\n }\n\n return {\n set(value) {\n target = value\n if (value === 0) {\n stiffness = SPRING_OUT.stiffness\n damping = SPRING_OUT.damping\n } else {\n stiffness = SPRING_IN.stiffness\n damping = SPRING_IN.damping\n }\n if (Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03) {\n current = target\n velocity = 0\n onChange(current)\n return\n }\n start()\n },\n get() {\n return current\n },\n destroy() {\n if (raf) cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\nfunction isWindow(scroller: HTMLElement | Window): scroller is Window {\n return scroller === window\n}\n\nfunction readScrollTop(scroller: HTMLElement | Window) {\n return isWindow(scroller)\n ? window.scrollY || document.documentElement.scrollTop\n : scroller.scrollTop\n}\n\nfunction maxScroll(scroller: HTMLElement | Window) {\n if (isWindow(scroller)) {\n return Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n }\n return Math.max(0, scroller.scrollHeight - scroller.clientHeight)\n}\n\nfunction clamp01(n: number) {\n return Math.max(0, Math.min(1, n))\n}\n\nfunction clampSigned(n: number) {\n return Math.max(-1, Math.min(1, n))\n}\n\nfunction remainingScroll(scroller: HTMLElement | Window) {\n return Math.max(0, maxScroll(scroller) - readScrollTop(scroller))\n}\n\n/** `1` in the body of the page, `0` at the bottom so the down-lean dies. */\nfunction endFade(scroller: HTMLElement | Window) {\n const left = remainingScroll(scroller)\n if (left <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((left - END_SLACK_PX) / zone)\n}\n\n/** `1` in the body of the page, `0` at the top so the up-lean dies. */\nfunction startFade(scroller: HTMLElement | Window) {\n const top = readScrollTop(scroller)\n if (top <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((top - END_SLACK_PX) / zone)\n}\n\n/** Map scroll delta to a signed -1…1 lean. Positive = scrolling down. */\nexport function leanFromDelta(deltaPx: number, ref = WHEEL_REF) {\n return clampSigned(deltaPx / ref)\n}\n\n/** Slow travel sits near 5°, a flick reaches 16°. */\nfunction tiltAngle(impulse: number) {\n const mag = Math.min(1, Math.abs(impulse))\n const deg = mag * mag * (TILT_PEAK - 5) + mag * 5\n return deg * Math.sign(impulse || 1)\n}\n\nfunction hingeY(\n scroller: HTMLElement | Window,\n plane: HTMLElement,\n tilt: number\n) {\n const vh = viewHeightOf(scroller)\n const localViewTop = readScrollTop(scroller) - plane.offsetTop\n if (Math.abs(tilt) < 0.35) return localViewTop + vh * 0.5\n return tilt > 0 ? localViewTop + vh : localViewTop\n}\n\nexport function applyFolioFrame(\n plane: HTMLElement,\n tilt: number,\n options: FolioRuntimeOptions = {},\n reducedMotion = false,\n originY?: number\n) {\n const blur = Math.max(0, options.blur ?? DEFAULT_BLUR)\n const perspective = Math.max(\n MIN_PERSPECTIVE,\n options.perspective ?? DEFAULT_PERSPECTIVE\n )\n const angle = reducedMotion ? 0 : tilt\n const amount = Math.min(1, Math.abs(angle) / TILT_PEAK)\n const blurPx = reducedMotion ? 0 : amount * blur\n\n plane.style.transformOrigin =\n originY !== undefined\n ? `50% ${originY.toFixed(1)}px`\n : Math.abs(angle) < 0.35\n ? \"50% 50%\"\n : angle > 0\n ? \"50% 100%\"\n : \"50% 0%\"\n plane.style.backfaceVisibility = \"hidden\"\n plane.style.transform = `perspective(${perspective}px) rotateX(${angle}deg)`\n plane.style.filter = \"none\"\n\n const veil = ensureBlurVeil(plane)\n if (blurPx < 0.08) {\n veil.style.cssText = \"display:none\"\n } else {\n const ramp =\n angle >= 0\n ? \"linear-gradient(to top, transparent 0%, transparent 32%, black 100%)\"\n : \"linear-gradient(to bottom, transparent 0%, transparent 32%, black 100%)\"\n veil.style.cssText = [\n \"pointer-events:none\",\n \"position:absolute\",\n \"inset:0\",\n \"z-index:2\",\n `backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `-webkit-backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `mask-image:${ramp}`,\n `-webkit-mask-image:${ramp}`,\n ].join(\";\")\n }\n plane.style.willChange = Math.abs(angle) > 0.08 ? \"transform\" : \"auto\"\n}\n\nfunction animateScroll(\n scroller: HTMLElement | Window,\n to: number,\n durationMs: number,\n signal: AbortSignal\n) {\n const from = readScrollTop(scroller)\n if (durationMs <= 0) {\n if (isWindow(scroller)) window.scrollTo({ top: to })\n else scroller.scrollTop = to\n return Promise.resolve()\n }\n\n const start = performance.now()\n return new Promise((resolve) => {\n const step = (now: number) => {\n if (signal.aborted) {\n resolve()\n return\n }\n const t = Math.min(1, (now - start) / durationMs)\n const e = t * t * t * (t * (t * 6 - 15) + 10)\n const y = from + (to - from) * e\n if (isWindow(scroller)) window.scrollTo({ top: y })\n else scroller.scrollTop = y\n if (t < 1) requestAnimationFrame(step)\n else resolve()\n }\n requestAnimationFrame(step)\n })\n}\n\n/** Lean the page while scrolling down, then spring flat. */\nexport async function playFolioDemo(detail: FolioPlayDetail = {}) {\n if (typeof window === \"undefined\") return\n window.dispatchEvent(new CustomEvent(FOLIO_PLAY, { detail }))\n const hold = detail.holdMs ?? 720\n await new Promise((r) => window.setTimeout(r, hold + 1800))\n}\n\nfunction viewHeightOf(scroller: HTMLElement | Window) {\n return isWindow(scroller) ? window.innerHeight : scroller.clientHeight\n}\n\nfunction ensureBlurVeil(plane: HTMLElement) {\n let veil = plane.querySelector(\"[data-slot='folio-blur-veil']\")\n if (!veil) {\n if (getComputedStyle(plane).position === \"static\") {\n plane.style.position = \"relative\"\n }\n veil = document.createElement(\"div\")\n veil.dataset.slot = \"folio-blur-veil\"\n veil.setAttribute(\"aria-hidden\", \"true\")\n plane.appendChild(veil)\n }\n return veil\n}\n\nexport function createFolio(options: {\n plane: HTMLElement\n scroller: HTMLElement | Window\n demoId?: string\n blur?: number\n perspective?: number\n returnMs?: number\n}): FolioInstance {\n const runtime: FolioRuntimeOptions = {\n blur: options.blur,\n perspective: options.perspective,\n returnMs: options.returnMs,\n }\n\n let reduced = false\n let playing = false\n let lastTop = readScrollTop(options.scroller)\n let lastTime = performance.now()\n let lastWheelAt = 0\n let wheelVel = 0\n let impulse = 0\n let gate = 1\n let gateFrom = 1\n let gateTarget = 1\n let gateT0 = 0\n let gateRaf = 0\n let idleTimer: ReturnType | null = null\n const playAbort = { current: new AbortController() }\n\n const paint = (tilt: number) => {\n applyFolioFrame(\n options.plane,\n tilt,\n runtime,\n reduced,\n hingeY(options.scroller, options.plane, tilt)\n )\n }\n\n const spring = createSpring(paint)\n\n const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const applyReduce = () => {\n reduced = media.matches && !playing\n if (reduced) {\n impulse = 0\n gate = 1\n spring.set(0)\n paint(0)\n }\n }\n applyReduce()\n media.addEventListener(\"change\", applyReduce)\n\n function returnMs() {\n return Math.max(0, runtime.returnMs ?? DEFAULT_RETURN_MS)\n }\n\n function sampleGate(now = performance.now()) {\n const ms = returnMs()\n if (ms <= 0) {\n gate = gateTarget\n return gate\n }\n const t = Math.min(1, (now - gateT0) / ms)\n const e = t * t * (3 - 2 * t)\n gate = gateFrom + (gateTarget - gateFrom) * e\n return gate\n }\n\n function goGate(to: number) {\n if (to === 0) {\n gate = 0\n gateFrom = 0\n gateTarget = 0\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateTarget !== 1) {\n gateFrom = sampleGate()\n gateTarget = 1\n gateT0 = performance.now()\n }\n if (gate >= 0.999) {\n gate = 1\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateRaf) return\n const tick = (now: number) => {\n sampleGate(now)\n applyLean()\n if (gateTarget === 1 && gate < 0.999) {\n gateRaf = requestAnimationFrame(tick)\n } else {\n if (gateTarget === 1) gate = 1\n gateRaf = 0\n }\n }\n gateRaf = requestAnimationFrame(tick)\n }\n\n function applyLean(leavingEdge = false) {\n if (reduced) {\n spring.set(0)\n return\n }\n if (playing) {\n spring.set(tiltAngle(impulse || 1))\n return\n }\n const down = impulse >= 0\n const fade = leavingEdge\n ? 1\n : down\n ? endFade(options.scroller)\n : startFade(options.scroller)\n if (fade <= 0.02) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n goGate(1)\n const g = sampleGate()\n spring.set(tiltAngle(impulse * fade * g))\n }\n\n function lean(amount: number, leavingEdge = false) {\n if (reduced) return\n const next = clampSigned(amount)\n const nextSign = Math.sign(next)\n const prevSign = Math.sign(impulse)\n if (nextSign !== 0 && prevSign !== 0 && nextSign !== prevSign) {\n impulse = next\n } else {\n const dir = nextSign || prevSign || 1\n const mag = Math.max(\n Math.abs(next),\n Math.abs(impulse) * 0.62 + Math.abs(next) * 0.38\n )\n impulse = mag * dir\n }\n applyLean(leavingEdge)\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n impulse = 0\n if (!playing) spring.set(0)\n }, FOLIO_IDLE_MS)\n }\n\n const onWheel = (event: Event) => {\n if (playing) return\n const wheel = event as WheelEvent\n // Lines (Firefox mouse wheels) and pages, in px.\n const dy =\n wheel.deltaMode === 1\n ? wheel.deltaY * 16\n : wheel.deltaMode === 2\n ? wheel.deltaY * viewHeightOf(options.scroller)\n : wheel.deltaY\n if (!dy) return\n const now = performance.now()\n const gap = now - lastWheelAt\n // A lone mouse notch counts as one frame's worth of travel.\n const speed = dy / Math.min(50, Math.max(8, gap))\n wheelVel = gap > WHEEL_GESTURE_GAP_MS ? speed : wheelVel * 0.6 + speed * 0.4\n lastWheelAt = now\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n const leavingEnd = atEnd && dy < 0\n const leavingStart = atStart && dy > 0\n if ((atEnd && !leavingEnd) || (atStart && !leavingStart)) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n lean(clampSigned(wheelVel / WHEEL_VEL_REF), leavingEnd || leavingStart)\n }\n\n const onScroll = () => {\n const now = performance.now()\n const top = readScrollTop(options.scroller)\n const dt = Math.max(8, now - lastTime)\n const vel = (top - lastTop) / dt\n lastTop = top\n lastTime = now\n if (playing) {\n applyLean()\n return\n }\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n if (atEnd && vel >= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (atStart && vel <= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (now - lastWheelAt < WHEEL_SCROLL_LOCK_MS) {\n applyLean(atEnd || atStart)\n return\n }\n lean(clampSigned(vel / VEL_REF), (atEnd && vel < 0) || (atStart && vel > 0))\n }\n\n const wheelTarget: EventTarget = isWindow(options.scroller)\n ? window\n : options.scroller\n wheelTarget.addEventListener(\"wheel\", onWheel, { passive: true })\n options.scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n const onResize = () => paint(spring.get())\n window.addEventListener(\"resize\", onResize)\n\n const onPlay = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n if (options.demoId) {\n if (!detail.target || detail.target !== options.demoId) return\n } else if (detail.target) {\n return\n }\n\n playAbort.current.abort()\n playAbort.current = new AbortController()\n const signal = playAbort.current.signal\n const hold = detail.holdMs ?? 720\n const scroller = detail.scrollRoot ?? options.scroller\n playing = true\n if (idleTimer) clearTimeout(idleTimer)\n applyReduce()\n\n void (async () => {\n const max = maxScroll(scroller)\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n const limit = Math.max(0, max - zone - END_SLACK_PX)\n let start = readScrollTop(scroller)\n if (start >= limit - 24) {\n impulse = 0\n gate = 1\n gateTarget = 1\n spring.set(0)\n if (isWindow(scroller)) window.scrollTo({ top: 0 })\n else scroller.scrollTop = 0\n lastTop = 0\n start = 0\n }\n const dest = Math.min(\n limit,\n start + Math.max(560, viewHeightOf(scroller) * 1.35)\n )\n impulse = 1\n gate = 1\n gateTarget = 1\n spring.set(TILT_PEAK)\n await animateScroll(scroller, dest, Math.max(1400, hold + 700), signal)\n if (signal.aborted) {\n playing = false\n applyReduce()\n return\n }\n playing = false\n applyReduce()\n impulse = 0\n spring.set(0)\n })()\n }\n\n window.addEventListener(FOLIO_PLAY, onPlay)\n paint(0)\n\n return {\n setOptions(next) {\n if (next.blur !== undefined) runtime.blur = next.blur\n if (next.perspective !== undefined) runtime.perspective = next.perspective\n if (next.returnMs !== undefined) runtime.returnMs = next.returnMs\n paint(spring.get())\n },\n destroy() {\n playing = false\n playAbort.current.abort()\n if (idleTimer) clearTimeout(idleTimer)\n if (gateRaf) cancelAnimationFrame(gateRaf)\n spring.destroy()\n options.plane.querySelector(\"[data-slot='folio-blur-veil']\")?.remove()\n media.removeEventListener(\"change\", applyReduce)\n wheelTarget.removeEventListener(\"wheel\", onWheel)\n options.scroller.removeEventListener(\"scroll\", onScroll)\n window.removeEventListener(\"resize\", onResize)\n window.removeEventListener(FOLIO_PLAY, onPlay)\n options.plane.style.transform = \"\"\n options.plane.style.filter = \"\"\n options.plane.style.willChange = \"\"\n options.plane.style.backfaceVisibility = \"\"\n options.plane.style.transformOrigin = \"\"\n },\n }\n}\n\nexport type FolioProps = FolioRuntimeOptions & {\n className?: string\n /** Page content that leans while you scroll. */\n children?: ReactNode\n /**\n * Bind to the window and tilt `contentSelector` instead of wrapping\n * children. Default `false` — this component *is* the scroller.\n */\n windowScroll?: boolean\n /**\n * Element to tilt when `windowScroll` is set.\n * Default `[data-folio-page]`.\n */\n contentSelector?: string\n /** Accessible name for the tilting page. */\n label?: string\n /**\n * Optional id for docs demos. `playFolioDemo({ target })` only\n * animates instances whose `demoId` matches.\n */\n demoId?: string\n}\n\n/**\n * The whole page leans in perspective while you scroll, with a matching\n * blur. When scroll stops, it springs to flat.\n */\nexport function Folio({\n className,\n children,\n blur = 4,\n perspective = 1000,\n returnMs = 520,\n windowScroll = false,\n contentSelector = \"[data-folio-page]\",\n label = \"Tilting page\",\n demoId,\n}: FolioProps) {\n const scrollerRef = useRef(null)\n const planeRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const scroller = windowScroll ? window : scrollerRef.current\n const plane = windowScroll\n ? document.querySelector(contentSelector)\n : planeRef.current\n if (!scroller || !plane) return\n\n instanceRef.current = createFolio({\n plane,\n scroller,\n demoId,\n blur,\n perspective,\n returnMs,\n })\n\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; bind once per scroller mode.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, contentSelector, demoId])\n\n useEffect(() => {\n instanceRef.current?.setOptions({ blur, perspective, returnMs })\n }, [blur, perspective, returnMs])\n\n if (windowScroll) return null\n\n return (\n \n \n {children}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/folio.tsx" } diff --git a/registry/folio/folio-vanilla.ts b/registry/folio/folio-vanilla.ts index 864d7c6..cfce71e 100644 --- a/registry/folio/folio-vanilla.ts +++ b/registry/folio/folio-vanilla.ts @@ -34,6 +34,14 @@ const MIN_PERSPECTIVE = 1000 const DEFAULT_RETURN_MS = 520 const VEL_REF = 2.4 const WHEEL_REF = 110 +/** + * Wheel speed, in px/ms, that leans all the way. Trackpads send a stream + * of small deltas, so a lean read per event barely moved; speed reads a + * fast swipe and a fast mouse flick the same. + */ +const WHEEL_VEL_REF = 1.1 +/** A wheel gap longer than this starts a fresh gesture. */ +const WHEEL_GESTURE_GAP_MS = 150 const WHEEL_SCROLL_LOCK_MS = 64 const END_SLACK_PX = 8 const SPRING_IN = { stiffness: 72, damping: 22 } @@ -274,9 +282,7 @@ function animateScroll( /** Lean the page while scrolling down, then spring flat. */ export async function playFolioDemo(detail: FolioPlayDetail = {}) { if (typeof window === "undefined") return - window.dispatchEvent( - new CustomEvent(FOLIO_PLAY, { detail }) - ) + window.dispatchEvent(new CustomEvent(FOLIO_PLAY, { detail })) const hold = detail.holdMs ?? 720 await new Promise((r) => window.setTimeout(r, hold + 1800)) } @@ -318,6 +324,7 @@ export function createFolio(options: { let lastTop = readScrollTop(options.scroller) let lastTime = performance.now() let lastWheelAt = 0 + let wheelVel = 0 let impulse = 0 let gate = 1 let gateFrom = 1 @@ -453,9 +460,21 @@ export function createFolio(options: { const onWheel = (event: Event) => { if (playing) return - const dy = (event as WheelEvent).deltaY + const wheel = event as WheelEvent + // Lines (Firefox mouse wheels) and pages, in px. + const dy = + wheel.deltaMode === 1 + ? wheel.deltaY * 16 + : wheel.deltaMode === 2 + ? wheel.deltaY * viewHeightOf(options.scroller) + : wheel.deltaY if (!dy) return - lastWheelAt = performance.now() + const now = performance.now() + const gap = now - lastWheelAt + // A lone mouse notch counts as one frame's worth of travel. + const speed = dy / Math.min(50, Math.max(8, gap)) + wheelVel = gap > WHEEL_GESTURE_GAP_MS ? speed : wheelVel * 0.6 + speed * 0.4 + lastWheelAt = now const atEnd = endFade(options.scroller) <= 0.02 const atStart = startFade(options.scroller) <= 0.02 const leavingEnd = atEnd && dy < 0 @@ -466,7 +485,7 @@ export function createFolio(options: { spring.set(0) return } - lean(leanFromDelta(dy), leavingEnd || leavingStart) + lean(clampSigned(wheelVel / WHEEL_VEL_REF), leavingEnd || leavingStart) } const onScroll = () => { diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index f08c1b9..ede6b3f 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -156,7 +156,7 @@ Exceptions: - Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. - `PhosphorScore` canvas is transparent in both themes; the parent paints the background. -- `Folio` tilt peaks at an internal 16°. It is not a prop. On a Mac trackpad the lean is easy to miss ([#29](https://github.com/radiumcoders/23rd.dev/issues/29)). Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. +- `Folio` tilt peaks at an internal 16°. It is not a prop. The lean follows wheel speed, so trackpads and mice lean alike. Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. `prefers-reduced-motion: reduce` is honored by the canvas and motion components (still frame, no tilt, no stretch). Do not add a `reducedMotion` prop. diff --git a/skills/23rd/components.md b/skills/23rd/components.md index a82b783..504ab6c 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -309,7 +309,7 @@ Section. The sheet tips one way on scroll down, the other on scroll up, blur on Best fit: a long editorial page that should feel like one sheet. -Not this: overscroll aurora (`stretchy-footer`). Do not use it when the user needs an obvious effect on a trackpad — issue [#29](https://github.com/radiumcoders/23rd.dev/issues/29) says the lean is barely visible on Mac. The docs “show effect” button calls `playFolioDemo`; that is a preview, not a stronger public tilt. +Not this: overscroll aurora (`stretchy-footer`). The lean follows wheel speed, so a fast trackpad swipe leans about as far as a mouse flick; slow reading-speed scrolls stay subtle. The docs “show effect” button calls `playFolioDemo`; that is a preview, not a stronger public tilt. Deps: none. Key props: `children`, `blur` (4), `perspective` (1000, floor 1000), `returnMs` (520), `windowScroll` (false), `contentSelector` (`[data-folio-page]`), `label` (`"Tilting page"`), `demoId`. diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index 01b8143..11ff369 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -158,7 +158,7 @@ export function Essay() { } ``` -If the effect is too subtle, say so. Do not invent a `tilt` prop. Issue #29 tracks the Mac trackpad case. `playFolioDemo({ target, holdMs })` only runs the preview on a matching `demoId`. +If the effect is too subtle, say so. Do not invent a `tilt` prop. `playFolioDemo({ target, holdMs })` only runs the preview on a matching `demoId`. Window mode: From 8fc52dbe6f3e402fde44615b7651d332123fb40a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 12:37:39 +0530 Subject: [PATCH 25/49] Add the landing page at /. The wordmark and the whole catalogue in dot matrix sit beside one live monitor: hovering or focusing a name runs that component on the screen with its install command, loading each engine only when picked. The temporary / redirect is gone, / joins the sitemap and is the app's start URL, and the 23rd wordmark now leads home. Closes #15 --- app/(marketing)/page.tsx | 50 +++++++++ app/manifest.ts | 2 +- app/sitemap.ts | 1 + components/docs-shell.tsx | 2 +- components/landing.tsx | 210 +++++++++++++++++++++++++++++++++++ components/monitor-feeds.tsx | 89 +++++++++++++++ components/site-header.tsx | 2 +- lib/tree.ts | 31 ++++++ next.config.mjs | 6 - 9 files changed, 384 insertions(+), 9 deletions(-) create mode 100644 app/(marketing)/page.tsx create mode 100644 components/landing.tsx create mode 100644 components/monitor-feeds.tsx diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..efe1b26 --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,50 @@ +import type { Root } from "fumadocs-core/page-tree" + +import { Landing, type CatalogSection } from "@/components/landing" +import { getGithubRepoUrl } from "@/lib/github" +import { buildPageMetadata, SITE_DESCRIPTION, SITE_TITLE } from "@/lib/seo" +import { source } from "@/lib/source" +import { sectionPageTree } from "@/lib/tree" + +export const metadata = buildPageMetadata({ + title: SITE_TITLE, + description: SITE_DESCRIPTION, + path: "/", + absoluteTitle: true, +}) + +const COMPONENTS = "/docs/components/" + +/** The component catalogue, grouped the way the docs sidebar groups it. */ +function catalog(): CatalogSection[] { + return sectionPageTree(source.getPageTree() as Root) + .map((section) => ({ + name: section.name ?? "", + items: section.items + .filter((item) => item.url.startsWith(COMPONENTS)) + .map((item) => { + const slug = item.url.slice(COMPONENTS.length) + const page = source.getPage(["components", slug]) + return { + title: item.title, + url: item.url, + slug, + description: page?.data.description ?? "", + } + }), + })) + .filter((section) => section.name && section.items.length > 0) +} + +export default function HomePage() { + const sections = catalog() + return ( +
+ +
+ ) +} diff --git a/app/manifest.ts b/app/manifest.ts index 3ae43ab..1231d49 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -7,7 +7,7 @@ export default function manifest(): MetadataRoute.Manifest { name: `${SITE_NAME} — ${SITE_TAGLINE}`, short_name: SITE_NAME, description: SITE_DESCRIPTION, - start_url: "/docs/getting-started", + start_url: "/", display: "standalone", background_color: "#000000", theme_color: "#000000", diff --git a/app/sitemap.ts b/app/sitemap.ts index 70f7e3d..016c848 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -18,6 +18,7 @@ export default function sitemap(): MetadataRoute.Sitemap { entries.push({ url, changeFrequency, priority }) } + add("/", 1) add("/sponsors", 0.6, "monthly") add("/terms", 0.2, "yearly") add("/privacy", 0.2, "yearly") diff --git a/components/docs-shell.tsx b/components/docs-shell.tsx index 0f2d05a..84cf764 100644 --- a/components/docs-shell.tsx +++ b/components/docs-shell.tsx @@ -45,7 +45,7 @@ type TreeNode = PageNode | SeparatorNode | FolderNode function Wordmark() { return ( diff --git a/components/landing.tsx b/components/landing.tsx new file mode 100644 index 0000000..56ae6fd --- /dev/null +++ b/components/landing.tsx @@ -0,0 +1,210 @@ +"use client" + +import { useEffect, useRef, useState, type CSSProperties } from "react" +import Link from "next/link" +import { RiArrowRightLine, RiGithubFill } from "@remixicon/react" + +import { CliCommand } from "@/components/cli-command" +import { MonitorFeed } from "@/components/monitor-feeds" +import { Triad } from "@/components/triad" +import { buttonVariants } from "@/components/ui/button" +import { FrameworkProvider } from "@/lib/framework" +import { cn } from "@/lib/utils" + +export type CatalogItem = { + title: string + url: string + /** Registry item name, e.g. `shader-sky`. */ + slug: string + description: string +} + +export type CatalogSection = { name: string; items: CatalogItem[] } + +/** Pointer hover waits this long before switching feeds, so sweeping the list doesn't boot every engine. */ +const HOVER_DELAY_MS = 140 + +/** Inside the monitor, `bg-background` is the screen, not the bench. */ +const screenTokens = { "--background": "var(--screen)" } as CSSProperties + +const FACTS = [ + "shadcn registry", + "React + Svelte 5", + "You own the source", + "Honors reduced motion", +] + +/** + * The landing page: the wordmark and the whole catalogue in dot matrix, + * beside one live monitor. Hovering or focusing a name puts that + * component on the screen; clicking opens its page. + */ +export function Landing({ + sections, + initial, + repoUrl, +}: { + sections: CatalogSection[] + initial: string + repoUrl: string +}) { + const items = sections.flatMap((section) => section.items) + const [selected, setSelected] = useState(initial) + const timer = useRef(null) + const current = items.find((item) => item.slug === selected) ?? items[0] + + function cancel() { + if (timer.current) window.clearTimeout(timer.current) + timer.current = null + } + + function select(slug: string, delay = 0) { + cancel() + if (delay === 0) { + setSelected(slug) + return + } + timer.current = window.setTimeout(() => setSelected(slug), delay) + } + + useEffect(() => { + return () => { + if (timer.current) window.clearTimeout(timer.current) + } + }, []) + + if (!current) return null + + return ( + +
+
+
+

+ 23rd +

+

+ Components that render — shaders, ASCII, dithering and motion + for shadcn/ui. Install one with the CLI, then it’s yours to + edit. +

+
+
    + {FACTS.map((fact) => ( +
  • {fact}
  • + ))} +
+
+ +
+
+ + + {current.title} + + + This one reacts to page scroll. Open its page to try it. + +
+ } + /> +
+ +
+
+

+ + {current.title} +

+ + Open {current.title} + +
+

+ {current.description} +

+ +
+ + + +
+ + + ) +} diff --git a/components/monitor-feeds.tsx b/components/monitor-feeds.tsx new file mode 100644 index 0000000..08f2cd0 --- /dev/null +++ b/components/monitor-feeds.tsx @@ -0,0 +1,89 @@ +"use client" + +import dynamic from "next/dynamic" +import type { ComponentType, ReactNode } from "react" + +/** + * One live mount per registry item for the landing monitor. Each engine + * loads only when its feed is picked, and unmounts (freeing its WebGL + * context) when another one takes over. + */ +function feed

( + load: () => Promise> +): ComponentType

{ + return dynamic(load, { ssr: false, loading: () => null }) +} + +const AsciiFluid = feed(() => + import("@/registry/ascii-fluid/ascii-fluid").then((m) => m.AsciiFluid) +) +const AsciiLogo = feed(() => + import("@/registry/ascii-logo/ascii-logo").then((m) => m.AsciiLogo) +) +const Dithered404 = feed(() => + import("@/registry/dithered-404/dithered-404").then((m) => m.Dithered404) +) +const GooeyColorPicker = feed(() => + import("@/registry/gooey-color-picker/gooey-color-picker").then( + (m) => m.GooeyColorPicker + ) +) +const LiveOrb = feed(() => + import("@/registry/live-orb/live-orb").then((m) => m.LiveOrb) +) +const LogoBurst = feed(() => + import("@/registry/logo-burst/logo-burst").then((m) => m.LogoBurst) +) +const PhosphorScore = feed(() => + import("@/registry/phosphor-score/phosphor-score").then( + (m) => m.PhosphorScore + ) +) +const RadiantLines = feed(() => + import("@/registry/radiant-lines/radiant-lines").then((m) => m.RadiantLines) +) +const ShaderFire = feed(() => + import("@/registry/shader-fire/shader-fire").then((m) => m.ShaderFire) +) +const ShaderGradient = feed(() => + import("@/registry/shader-gradient/shader-gradient").then( + (m) => m.ShaderGradient + ) +) +const ShaderSky = feed(() => + import("@/registry/shader-sky/shader-sky").then((m) => m.ShaderSky) +) + +const FEEDS: Record ReactNode> = { + "ascii-fluid": () => , + "ascii-logo": () => , + "dithered-404": () => , + "gooey-color-picker": () => ( +

+ +
+ ), + "live-orb": () => ( +
+ +
+ ), + "logo-burst": () => , + "phosphor-score": () => , + "radiant-lines": () => , + "shader-fire": () => , + "shader-gradient": () => , + "shader-sky": () => , +} + +/** Renders the live feed for a registry item, or `fallback` if it has none. */ +export function MonitorFeed({ + slug, + fallback, +}: { + slug: string + fallback: ReactNode +}) { + const render = FEEDS[slug] + return render ? render() : fallback +} diff --git a/components/site-header.tsx b/components/site-header.tsx index 8dcf255..ecb0cca 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -20,7 +20,7 @@ export function SiteHeader({ githubStars }: { githubStars?: number | null }) { return (
diff --git a/lib/tree.ts b/lib/tree.ts index cebb04e..7854645 100644 --- a/lib/tree.ts +++ b/lib/tree.ts @@ -29,3 +29,34 @@ export function flattenPageTree(root: Root): PageTreeItem[] { root.children.forEach(visit) return items } + +export interface PageTreeSection { + /** Separator label from `meta.json`, or `null` for pages before the first one. */ + name: string | null + items: PageTreeItem[] +} + +/** Pages grouped under the `---Label---` separators they follow. */ +export function sectionPageTree(root: Root): PageTreeSection[] { + const sections: PageTreeSection[] = [{ name: null, items: [] }] + + function visit(node: Root["children"][number]) { + if (node.type === "separator") { + const name = extractName(node) + if (name) sections.push({ name, items: [] }) + } else if (node.type === "page" && !node.external) { + sections.at(-1)!.items.push({ title: extractName(node), url: node.url }) + } else if (node.type === "folder") { + if (node.index) { + sections.at(-1)!.items.push({ + title: extractName(node.index), + url: node.index.url, + }) + } + node.children.forEach(visit) + } + } + + root.children.forEach(visit) + return sections.filter((section) => section.items.length > 0) +} diff --git a/next.config.mjs b/next.config.mjs index a2e64f3..7ebb9ad 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,12 +11,6 @@ const config = { }, async redirects() { return [ - // Temporary until the landing page lands at `/`. - { - source: "/", - destination: "/docs/getting-started", - permanent: false, - }, { source: "/docs", destination: "/docs/getting-started", From cf027807e52241527df1f4440ae6730c7185123c Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 13:16:27 +0530 Subject: [PATCH 26/49] Rethink Shader Fire as cel-shaded flames with embers. The soft orange wash gives way to graphic fire: a turbulent temperature field, stretched tall and ridged so it curls into pointed tongues, drawn as four flat bands (ember, flame, core, and a hot center). A new embers prop sends three parallax layers of sparks drifting up, a warm glow flickers along the floor, the fire ignites on first paint, and the pointer draws a plume of flame up toward it. Dither now prints the band edges as ordered dots. New light and dark palettes; speed and intensity default to 1, and it pauses off-screen and in hidden tabs. --- .cursor/skills/23rd/SKILL.md | 2 +- .cursor/skills/23rd/apis.md | 7 +- .cursor/skills/23rd/components.md | 8 +- .cursor/skills/23rd/recipes.md | 2 +- README.md | 2 +- content/docs/components/shader-fire.mdx | 25 +- .../og/docs/components/shader-fire/image.png | Bin 45493 -> 46265 bytes public/r/registry.json | 4 +- public/r/shader-fire-svelte.json | 4 +- public/r/shader-fire.json | 4 +- registry/shader-fire/registry.json | 4 +- registry/shader-fire/shader-fire-demo.tsx | 59 +- registry/shader-fire/shader-fire-vanilla.ts | 560 +++++++++++------- registry/shader-fire/shader-fire.svelte | 17 +- registry/shader-fire/shader-fire.tsx | 42 +- skills/23rd/SKILL.md | 2 +- skills/23rd/apis.md | 7 +- skills/23rd/components.md | 8 +- skills/23rd/recipes.md | 2 +- 19 files changed, 463 insertions(+), 296 deletions(-) diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index ede6b3f..c708d21 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -171,7 +171,7 @@ Categories match `content/docs/components/meta.json`. | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | -| `shader-fire` | Shaders | Sparse fire tongues rising from the bottom | React + Svelte | +| `shader-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | | `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | | `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | React + Svelte | | `live-orb` | Characters | Lit sphere; eyes follow the pointer | React + Svelte | diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 167c924..e975d29 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -151,10 +151,11 @@ type ShaderGradientProps = { ```ts type ShaderFireProps = { - colors?: string[] // ember, flame, highlight - speed?: number // 0.55 - intensity?: number // 0.55 + colors?: string[] // ember, flame, core + speed?: number // 1 + intensity?: number // 1 height?: number // 0.45 + embers?: number // 0.6 interactive?: boolean // true dither?: boolean // false pixelSize?: number // 1 diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index 504ab6c..f6e2dbe 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -145,13 +145,13 @@ Pitfalls: custom `colors` stick; they do not cross-fade into the dark palette. S ## shader-fire -Shader. Sparse tongues from the bottom edge. Ember / flame / highlight. +Shader. Cel-shaded flames lick up from the bottom edge, sparks drift above. Ember / flame / core. -Best fit: heat behind a hero. `dither` turns the wash into Bayer pixels. +Best fit: fire along the floor of a hero. `dither` prints the bands as Bayer pixels. Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). -Deps: none. Key props: `colors`, `speed` (0.55), `intensity` (0.55), `height` (0.45), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. +Deps: none. Key props: `colors`, `speed` (1), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. ```tsx "use client" @@ -167,7 +167,7 @@ export function Hero() { } ``` -Pitfalls: `interactive` adds heat under the pointer. `dither` defaults off. Custom `colors` do not follow theme. Light `#9C3A24 #C96A32 #E6C4A0`. Dark `#A33A18 #D4682A #E8B45A`. +Pitfalls: `interactive` makes a plume of flame reach up toward the pointer. `dither` defaults off. `embers={0}` turns sparks off. Custom `colors` do not follow theme. Light `#D8341A #F9731E #FFBA3A`. Dark `#B4200A #FF6512 #FFC04A`. ## shader-sky diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index 11ff369..c4a4e95 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -33,7 +33,7 @@ Swap the component, keep the shell: | Brief | Component | Extra props | | --- | --- | --- | | Fire along the floor | `ShaderFire` | `height={0.45}` | -| Pixel fire | `ShaderFire` | `dither pixelSize={1}` | +| Pixel fire | `ShaderFire` | `dither pixelSize={3}` | | Blue sky | `ShaderSky` | leave `colors` unset | | Rain / dusk sky | `ShaderSky` | `theme="dark"` or `html.dark` | | Window glass | `ShaderSky` | `glass glassSize={7}` | diff --git a/README.md b/README.md index a149d2d..138a9ac 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ npx skills add radiumcoders/23rd.dev --skill 23rd -g | [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 | | [Radiant Lines](https://23rd.dev/docs/components/radiant-lines) | `@23rd/radiant-lines` | Hyperspace starfield background; warp speed driven by scroll | -| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Sparse 2D fire wash — tongues rise from the bottom behind a landing hero | +| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Cel-shaded flames and sparks rising from the bottom of a landing hero | | [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections | | [Shader Sky](https://23rd.dev/docs/components/shader-sky) | `@23rd/shader-sky` | Clear blue or rain behind a hero — drifting clouds, optional window-glass film | | [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back | diff --git a/content/docs/components/shader-fire.mdx b/content/docs/components/shader-fire.mdx index def98b4..97d304d 100644 --- a/content/docs/components/shader-fire.mdx +++ b/content/docs/components/shader-fire.mdx @@ -1,11 +1,11 @@ --- title: Shader Fire -description: A sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, sit behind UI, follow the theme, optional pointer heat. +description: Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above them, and the fire reaches for the pointer. --- import { ShaderFireDemo } from "@/registry/shader-fire/shader-fire-demo" -Not a wall of flame. Use it as heat under a hero or wait screen so the page feels finished without covering the copy. Sienna / amber / dusty peach on paper in light mode; visible coals on slate in dark. Toggle **Dither** for an ordered Bayer print of the same field. Press `d` to switch theme. +Flat, graphic fire rather than a photo of one. Four crisp bands (ember, flame, core, and a hot center) curl up from the bottom edge, sparks drift up past the copy, and a warm glow flickers under it all. Move the pointer over it and a plume of flame reaches up toward it. Toggle **Dither** to print the same bands as ordered Bayer pixels. Press `d` to switch theme. ## Preview @@ -39,10 +39,10 @@ export function Hero() { New

- Heat under the headline + Ship something that catches

- The fire stays a wash. The page stays readable. + Flames along the floor. The copy stays clear.

@@ -63,10 +63,10 @@ export function Hero() { New

- Heat under the headline + Ship something that catches

- The fire stays a wash. The page stays readable. + Flames along the floor. The copy stays clear.

@@ -91,17 +91,17 @@ Force a palette if the parent theme should not drive it: -Print the same field as ordered Bayer pixels: +Print the same bands as ordered Bayer pixels: ```tsx - + ``` ```svelte - + ``` @@ -110,10 +110,11 @@ Print the same field as ordered Bayer pixels: | Prop | Type | Default | | --- | --- | --- | -| `colors` | `string[]` | ember / flame / highlight | -| `speed` | `number` | `0.55` | -| `intensity` | `number` | `0.55` | +| `colors` | `string[]` | ember / flame / core | +| `speed` | `number` | `1` | +| `intensity` | `number` | `1` | | `height` | `number` | `0.45` | +| `embers` | `number` | `0.6` | | `interactive` | `boolean` | `true` | | `dither` | `boolean` | `false` | | `pixelSize` | `number` | `1` | diff --git a/public/og/docs/components/shader-fire/image.png b/public/og/docs/components/shader-fire/image.png index e6942cb852a3a52baa1a72b0a8cdb4ab79484d7c..75d1d565314262ebecc5807b6076de78029b0d48 100644 GIT binary patch literal 46265 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 iH2z(Y0;h5A34Jh2nEgcmEefAxw+_ literal 45493 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 BM~naf diff --git a/public/r/registry.json b/public/r/registry.json index e259bc3..94831c4 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -245,7 +245,7 @@ { "name": "shader-fire", "title": "Shader Fire", - "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { "path": "registry/shader-fire/shader-fire.tsx", @@ -258,7 +258,7 @@ { "name": "shader-fire-svelte", "title": "Shader Fire", - "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "dependencies": [], "files": [ { diff --git a/public/r/shader-fire-svelte.json b/public/r/shader-fire-svelte.json index 1c2d0f9..828e28e 100644 --- a/public/r/shader-fire-svelte.json +++ b/public/r/shader-fire-svelte.json @@ -2,12 +2,12 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "shader-fire-svelte", "title": "Shader Fire", - "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "dependencies": [], "files": [ { "path": "registry/shader-fire/shader-fire.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/shader-fire.svelte" } diff --git a/public/r/shader-fire.json b/public/r/shader-fire.json index f1ba1f5..aef5103 100644 --- a/public/r/shader-fire.json +++ b/public/r/shader-fire.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "shader-fire", "title": "Shader Fire", - "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { "path": "registry/shader-fire/shader-fire.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderFireOptions = {\n /** Ember / flame / highlight hex */\n colors?: string[]\n /** Rise speed. Default 0.55 */\n speed?: number\n /** Coverage 0–1. Default 0.55 */\n intensity?: number\n /** How far tongues climb 0–1. Default 0.45 */\n height?: number\n /** Local heat under the pointer. Default true */\n interactive?: boolean\n /** Ordered Bayer pixels instead of a soft wash. 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?: ShaderFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderFireInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Warm ink on paper — sienna / amber / dusty peach */\nexport const LIGHT_COLORS = [\"#9C3A24\", \"#C96A32\", \"#E6C4A0\"]\n/** Visible coals on slate — ember / flame / highlight */\nexport const DARK_COLORS = [\"#A33A18\", \"#D4682A\", \"#E8B45A\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#FCFBF9\",\n backgroundImage: [\n \"radial-gradient(80% 42% at 50% 108%, #9C3A24 0%, transparent 70%)\",\n \"radial-gradient(36% 28% at 22% 100%, #C96A32 0%, transparent 72%)\",\n \"radial-gradient(32% 24% at 78% 100%, #C96A32 0%, transparent 72%)\",\n \"radial-gradient(22% 16% at 50% 100%, #E6C4A0 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#08090C\",\n backgroundImage: [\n \"radial-gradient(80% 42% at 50% 108%, #A33A18 0%, transparent 70%)\",\n \"radial-gradient(36% 28% at 22% 100%, #D4682A 0%, transparent 72%)\",\n \"radial-gradient(32% 24% at 78% 100%, #D4682A 0%, transparent 72%)\",\n \"radial-gradient(22% 16% at 50% 100%, #E8B45A 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\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 float u_intensity;\nuniform float u_height;\nuniform float u_dark;\nuniform float u_dither;\nuniform float u_pixelSize;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec2 u_mouse;\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\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 vec2 hi = floor(c * 0.5);\n vec2 lo = mod(c, 2.0);\n return bayer2(lo) * 4.0 + bayer2(hi);\n}\n\nfloat bayer8(vec2 p) {\n vec2 c = mod(floor(p), 8.0);\n vec2 hi = floor(c * 0.5);\n vec2 lo = mod(c, 2.0);\n return (bayer4(hi) * 4.0 + bayer2(lo) + 0.5) / 64.0;\n}\n\nfloat fireField(vec2 uv, float aspect, float t) {\n float climb = mix(0.22, 0.82, clamp(u_height, 0.0, 1.0));\n\n // Column space — sway as they rise\n vec2 q = vec2(uv.x * aspect * 1.85, uv.y * 1.55 - t * 0.48);\n q.x += sin(uv.y * 7.0 + t * 1.6) * 0.045 * (0.25 + uv.y);\n\n // Two-pass domain warp — curling tongues, not blobs\n vec2 w1 = vec2(\n fbm(q * 1.7 + vec2(t * 0.28, t * 0.22)),\n fbm(q * 1.7 + vec2(-t * 0.24, t * 0.31) + 5.2)\n );\n q += (w1 - 0.5) * 0.58;\n vec2 w2 = vec2(\n fbm(q * 3.4 - vec2(t * 0.52, 0.0)),\n fbm(q * 3.4 + vec2(0.0, t * 0.44) + 2.7)\n );\n q += (w2 - 0.5) * 0.26;\n\n float shape = fbm(q * 2.35);\n float detail = fbm(q * 7.2 + vec2(0.0, t * 1.7));\n float flicker = 0.9 + 0.1 * sin(t * 13.0 + uv.x * 9.0 + shape * 6.0);\n float field = (shape * 0.68 + detail * 0.32) * flicker;\n\n // Hotter, wider base; thinner breakaways as they climb\n float taper = pow(clamp(1.0 - uv.y / max(climb, 0.05), 0.0, 1.0), 1.35);\n float core = pow(clamp(1.0 - uv.y / max(climb * 0.42, 0.04), 0.0, 1.0), 2.1);\n field = field * taper + core * shape * 0.38;\n\n float md = length((uv - u_mouse) * vec2(aspect, 1.0));\n field += exp(-md * 6.8) * 0.28 * (0.55 + 0.45 * shape);\n\n field *= smoothstep(0.0, 0.05, uv.y + 0.015);\n field = smoothstep(0.22, 0.74, field);\n\n float intensity = mix(0.34, 1.18, clamp(u_intensity, 0.0, 1.0));\n return clamp(field * intensity, 0.0, 1.0);\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 px = max(u_pixelSize, 1.0);\n\n vec2 sampleUv = gl_FragCoord.xy / u_resolution.xy;\n vec2 cell = floor(gl_FragCoord.xy / px);\n if (u_dither > 0.5) {\n sampleUv = (cell + 0.5) * px / u_resolution.xy;\n }\n\n float field = fireField(sampleUv, aspect, t);\n\n if (u_dither > 0.5) {\n float bit = step(bayer8(cell), field);\n field = bit * mix(0.45, 1.0, field);\n }\n\n vec3 ember = u_c1;\n vec3 flame = u_c2;\n vec3 highlight = u_c3;\n vec3 tint = mix(ember, flame, smoothstep(0.08, 0.48, field));\n tint = mix(tint, highlight, pow(smoothstep(0.42, 0.96, field), 1.55));\n\n vec3 paper = u_dark < 0.5\n ? vec3(0.992, 0.986, 0.978)\n : vec3(0.03, 0.032, 0.042);\n // Light: watercolor on paper — more air, less orange stain\n float cover = u_dither > 0.5\n ? field\n : field * (u_dark < 0.5 ? 0.68 : 0.84);\n vec3 col = mix(paper, tint, clamp(cover, 0.0, 1.0));\n if (u_dark < 0.5 && u_dither < 0.5) {\n float haze = field * (1.0 - field) * 0.12;\n col = mix(col, paper, haze);\n }\n\n if (u_dither < 0.5) {\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.01;\n }\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\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.77, 0.38, 0.18]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\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: ShaderFireTheme): 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 \"ShaderFire: 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 * Sparse 2D fire wash — tongues rise from the bottom behind UI.\n * Theme-aware light / dusk.\n */\nexport function createShaderFire(\n canvas: HTMLCanvasElement,\n initial: ShaderFireOptions = {}\n): ShaderFireInstance | null {\n let options: Required<\n Pick<\n ShaderFireOptions,\n | \"speed\"\n | \"intensity\"\n | \"height\"\n | \"interactive\"\n | \"dither\"\n | \"pixelSize\"\n | \"theme\"\n >\n > &\n ShaderFireOptions = {\n speed: 0.55,\n intensity: 0.55,\n height: 0.45,\n interactive: true,\n dither: false,\n pixelSize: 1,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.12 }\n const targetMouse = { x: 0.5, y: 0.12 }\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uHeight = gl.getUniformLocation(program, \"u_height\")\n const uDark = gl.getUniformLocation(program, \"u_dark\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n const uDither = gl.getUniformLocation(program, \"u_dither\")\n const uPixelSize = gl.getUniformLocation(program, \"u_pixelSize\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 <= 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\n const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)\n )\n\n mouse.x += (targetMouse.x - mouse.x) * 0.05\n mouse.y += (targetMouse.y - mouse.y) * 0.05\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const px = Math.max(1, options.pixelSize ?? 1) * dpr\n\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.55))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.55)))\n gl.uniform1f(uHeight, Math.min(1, Math.max(0, options.height ?? 0.45)))\n gl.uniform1f(uDark, dark ? 1 : 0)\n gl.uniform1f(uDither, options.dither ? 1 : 0)\n gl.uniform1f(uPixelSize, px)\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.uniform2f(\n uMouse,\n options.interactive ? mouse.x : 0.5,\n options.interactive ? mouse.y : 0.12\n )\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderFireProps = Omit & {\n className?: string\n}\n\n/**\n * Sparse 2D fire wash — tongues rise from the bottom behind UI.\n * Theme-aware light / dusk.\n */\nexport function ShaderFire({\n className,\n colors,\n speed = 0.55,\n intensity = 0.55,\n height = 0.45,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderFireProps) {\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 = createShaderFire(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 \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderFireOptions = {\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 `1`. */\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 /** How many sparks drift up, 0–1. `0` turns them off. Default `0.6`. */\n embers?: 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?: ShaderFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderFireInstance = {\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 = 1\nexport const DEFAULT_INTENSITY = 1\nexport const DEFAULT_HEIGHT = 0.45\nexport const DEFAULT_EMBERS = 0.6\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_embers;\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' hashes — no sin(), so they stay 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 hash2(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\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// Sparks: three parallax layers of cells scrolling up, one spark in a few.\nfloat embers(vec2 uv, float aspect, float t, float plume) {\n if (u_embers <= 0.0) return 0.0;\n float climb = max(climbOf(), 0.05);\n float sum = 0.0;\n for (int i = 0; i < 3; i++) {\n float fi = float(i);\n float scale = 8.0 + fi * 7.0;\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float column = floor(p.x);\n float lane = hash(vec2(column, fi * 7.1));\n p.y -= t * (0.34 - fi * 0.06) * scale * (0.7 + 0.6 * lane);\n\n vec2 id = floor(p);\n vec2 h = hash2(id + fi * 17.3);\n float rate = u_embers * (0.16 + fi * 0.07) * (1.0 + plume * 2.5);\n if (h.x > rate) continue;\n\n vec2 c = 0.5 + (h - 0.5) * 0.45;\n c.x += sin(t * (1.4 + h.y * 2.2) + h.x * 40.0) * 0.16;\n vec2 d = fract(p) - c;\n d.y *= 0.5;\n float r = mix(0.07, 0.13, h.y) * (1.0 - fi * 0.22);\n float spark = 1.0 - smoothstep(0.0, r, length(d));\n spark *= spark;\n\n float twinkle = 0.55 + 0.45 * sin(t * (5.0 + h.y * 9.0) + h.x * 90.0);\n // Born in the flames, burnt out a little above them.\n float life = 1.0 - smoothstep(climb * 0.5, climb * 2.6, uv.y);\n life *= smoothstep(0.0, climb * 0.35, uv.y);\n sum += spark * twinkle * life * (1.0 - fi * 0.25);\n }\n return min(sum, 1.0);\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 float spark = embers(uv, aspect, t, plume);\n spark = smoothstep(0.1 + q * 0.15, 0.3 + q * 0.15, spark);\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 col = mix(col, u_dark > 0.5 ? mix(u_c1, u_c2, 0.6) : u_c0, spark);\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: ShaderFireTheme): 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 \"ShaderFire: 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 fire along the bottom edge: turbulent tongues colored by heat,\n * sparks drifting up, and a warm glow that flickers with them. Theme-aware;\n * pauses off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`.\n */\nexport function createShaderFire(\n canvas: HTMLCanvasElement,\n initial: ShaderFireOptions = {}\n): ShaderFireInstance | null {\n let options: ShaderFireOptions = {\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uIntensity = u(\"u_intensity\")\n const uHeight = u(\"u_height\")\n const uEmbers = u(\"u_embers\")\n const uIgnite = u(\"u_ignite\")\n const uDark = u(\"u_dark\")\n const uDither = u(\"u_dither\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n const uBase = u(\"u_base\")\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 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 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 if (raf) 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 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(uEmbers, numberOr(options.embers, DEFAULT_EMBERS, 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 resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderFireProps = Omit & {\n className?: string\n}\n\n/**\n * Cel-shaded flames licking up from the bottom edge, with sparks drifting\n * above them. Heat reaches for the pointer. Theme-aware.\n */\nexport function ShaderFire({\n className,\n colors,\n speed = DEFAULT_SPEED,\n intensity = DEFAULT_INTENSITY,\n height = DEFAULT_HEIGHT,\n embers = DEFAULT_EMBERS,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderFireProps) {\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 = createShaderFire(canvas, {\n colors,\n speed,\n intensity,\n height,\n embers,\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 embers,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n intensity,\n height,\n embers,\n interactive,\n dither,\n pixelSize,\n theme,\n ])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* The canvas carries the radius itself: a WebGL layer can slip past a\n parent's rounded clip on some GPUs. */}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/shader-fire.tsx" } diff --git a/registry/shader-fire/registry.json b/registry/shader-fire/registry.json index a32ade4..43697ff 100644 --- a/registry/shader-fire/registry.json +++ b/registry/shader-fire/registry.json @@ -5,7 +5,7 @@ "name": "shader-fire", "type": "registry:ui", "title": "Shader Fire", - "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { "path": "shader-fire.tsx", @@ -18,7 +18,7 @@ "name": "shader-fire-svelte", "type": "registry:ui", "title": "Shader Fire", - "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { "path": "shader-fire.svelte", diff --git a/registry/shader-fire/shader-fire-demo.tsx b/registry/shader-fire/shader-fire-demo.tsx index 48e12f2..9014ef6 100644 --- a/registry/shader-fire/shader-fire-demo.tsx +++ b/registry/shader-fire/shader-fire-demo.tsx @@ -14,6 +14,10 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { DARK_COLORS, + DEFAULT_EMBERS, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_COLORS, ShaderFire, } from "@/registry/shader-fire/shader-fire" @@ -38,9 +42,10 @@ export function ShaderFireDemo() { const defaults = useMemo( () => ({ - speed: 0.55, - intensity: 0.55, - height: 0.45, + speed: DEFAULT_SPEED, + intensity: DEFAULT_INTENSITY, + height: DEFAULT_HEIGHT, + embers: DEFAULT_EMBERS, interactive: true, overlay: true, dither: false, @@ -75,6 +80,7 @@ export function ShaderFireDemo() { speed={props.speed} intensity={props.intensity} height={props.height} + embers={props.embers} interactive={props.interactive} dither={props.dither} pixelSize={props.pixelSize} @@ -84,18 +90,18 @@ export function ShaderFireDemo() { <>

- Atmosphere + Now burning

- Heat under the headline + Ship something that catches

- Sparse tongues rise from the bottom. Copy stays in the quiet - middle; the fire stays a wash. + Flames lick up from the floor and sparks drift past the copy. + Move the pointer and the fire reaches for it.

@@ -118,12 +124,14 @@ export function ShaderFireDemo() { onReset={resetProps} component="ShaderFire" snippetProps={{ - speed: props.speed, - intensity: props.intensity, - height: props.height, - interactive: props.interactive, - dither: props.dither, - pixelSize: props.pixelSize, + speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, + intensity: + props.intensity === DEFAULT_INTENSITY ? undefined : props.intensity, + height: props.height === DEFAULT_HEIGHT ? undefined : props.height, + embers: props.embers === DEFAULT_EMBERS ? undefined : props.embers, + interactive: props.interactive ? undefined : false, + dither: props.dither || undefined, + pixelSize: props.pixelSize === 1 ? undefined : props.pixelSize, colors: useAutoTheme ? undefined : props.colors, }} > @@ -132,24 +140,25 @@ export function ShaderFireDemo() { colors={props.colors} palettes={[ palette, - ["#1E3A5F", "#4A90A4", "#C5E4E7"], - ["#5B1E6E", "#C44569", "#FFA07A"], - ["#1A1A1A", "#6B4F3A", "#E8E4DC"], + // Coolest to hottest: ember, flame, core. + ["#1D3FD1", "#2F8CFF", "#8EE3FF"], + ["#0F7A3A", "#3DDC5A", "#D4FF6B"], + ["#6A1BB0", "#D63FD2", "#FF9DE2"], ]} onChange={(colors) => updateProp("colors", colors)} /> updateProp("speed", v)} /> updateProp("intensity", v)} @@ -162,6 +171,14 @@ export function ShaderFireDemo() { step={0.05} onChange={(v) => updateProp("height", v)} /> + updateProp("embers", v)} + /> updateProp("interactive", v)} /> diff --git a/registry/shader-fire/shader-fire-vanilla.ts b/registry/shader-fire/shader-fire-vanilla.ts index c6a6736..06eeaa4 100644 --- a/registry/shader-fire/shader-fire-vanilla.ts +++ b/registry/shader-fire/shader-fire-vanilla.ts @@ -1,19 +1,24 @@ export type ShaderFireTheme = "light" | "dark" | "auto" export type ShaderFireOptions = { - /** Ember / flame / highlight hex */ + /** + * Three colors, coolest to hottest: ember, flame, core. In dark mode the + * densest heat still burns past the core toward white. + */ colors?: string[] - /** Rise speed. Default 0.55 */ + /** How fast the flames rise. Default `1`. */ speed?: number - /** Coverage 0–1. Default 0.55 */ + /** Opacity of the fire and its glow, 0–1. Default `1`. */ intensity?: number - /** How far tongues climb 0–1. Default 0.45 */ + /** How far the flames climb, 0–1. Default `0.45`. */ height?: number - /** Local heat under the pointer. Default true */ + /** How many sparks drift up, 0–1. `0` turns them off. Default `0.6`. */ + embers?: number + /** A plume of heat reaches up toward the pointer. Default `true`. */ interactive?: boolean - /** Ordered Bayer pixels instead of a soft wash. Default false */ + /** Ordered Bayer pixels instead of smooth flames. Default `false`. */ dither?: boolean - /** Dither cell size in CSS px. Default `1` */ + /** Dither cell size in CSS px. Default `1`. */ pixelSize?: number /** * Palette mode. Default `auto` follows shadcn / next-themes @@ -29,30 +34,34 @@ export type ShaderFireInstance = { destroy: () => void } -/** Warm ink on paper — sienna / amber / dusty peach */ -export const LIGHT_COLORS = ["#9C3A24", "#C96A32", "#E6C4A0"] -/** Visible coals on slate — ember / flame / highlight */ -export const DARK_COLORS = ["#A33A18", "#D4682A", "#E8B45A"] - -export const LIGHT_FALLBACK = { - backgroundColor: "#FCFBF9", - backgroundImage: [ - "radial-gradient(80% 42% at 50% 108%, #9C3A24 0%, transparent 70%)", - "radial-gradient(36% 28% at 22% 100%, #C96A32 0%, transparent 72%)", - "radial-gradient(32% 24% at 78% 100%, #C96A32 0%, transparent 72%)", - "radial-gradient(22% 16% at 50% 100%, #E6C4A0 0%, transparent 70%)", - ].join(", "), -} as const - -export const DARK_FALLBACK = { - backgroundColor: "#08090C", - backgroundImage: [ - "radial-gradient(80% 42% at 50% 108%, #A33A18 0%, transparent 70%)", - "radial-gradient(36% 28% at 22% 100%, #D4682A 0%, transparent 72%)", - "radial-gradient(32% 24% at 78% 100%, #D4682A 0%, transparent 72%)", - "radial-gradient(22% 16% at 50% 100%, #E8B45A 0%, transparent 70%)", - ].join(", "), -} as const +/** Brick, flame orange and marigold — saturated enough to hold on paper. */ +export const LIGHT_COLORS = ["#D8341A", "#F9731E", "#FFBA3A"] +/** Deep ember, flame and gold; the tonemap carries the core to white-hot. */ +export const DARK_COLORS = ["#B4200A", "#FF6512", "#FFC04A"] +const LIGHT_BASE = "#FCFBF9" +const DARK_BASE = "#08090C" + +export const DEFAULT_SPEED = 1 +export const DEFAULT_INTENSITY = 1 +export const DEFAULT_HEIGHT = 0.45 +export const DEFAULT_EMBERS = 0.6 + +function fallback(colors: string[], base: string) { + const [ember, flame, core] = colors + return { + backgroundColor: base, + backgroundImage: [ + `radial-gradient(24% 18% at 50% 100%, ${core} 0%, transparent 70%)`, + `radial-gradient(36% 30% at 24% 100%, ${flame} 0%, transparent 72%)`, + `radial-gradient(34% 28% at 76% 100%, ${flame} 0%, transparent 72%)`, + `radial-gradient(90% 50% at 50% 110%, ${ember} 0%, transparent 72%)`, + ].join(", "), + } as const +} + +/** Shown before the first frame and wherever WebGL isn't available. */ +export const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE) +export const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE) const VERT = ` attribute vec2 a_position; @@ -66,42 +75,55 @@ precision highp float; uniform vec2 u_resolution; uniform float u_time; -uniform float u_speed; uniform float u_intensity; uniform float u_height; +uniform float u_embers; +uniform float u_ignite; uniform float u_dark; uniform float u_dither; -uniform float u_pixelSize; +uniform float u_pixel; +uniform float u_pointer; +uniform vec2 u_mouse; +// Ember, flame, core and the paper / ink underneath, in linear light. +uniform vec3 u_c0; uniform vec3 u_c1; uniform vec3 u_c2; -uniform vec3 u_c3; -uniform vec2 u_mouse; +uniform vec3 u_base; +// Dave Hoskins' hashes — no sin(), so they stay stable on mobile GPUs. float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 hash2(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); - float a = hash(i); - float b = hash(i + vec2(1.0, 0.0)); - float c = hash(i + vec2(0.0, 1.0)); - float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); - return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; + return mix( + mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); } float fbm(vec2 p) { float v = 0.0; float a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6); - for (int i = 0; i < 5; i++) { + for (int i = 0; i < 3; i++) { v += a * noise(p); p = m * p; a *= 0.5; } - return v; + return v / 0.875; } // Classic Bayer (WebGL1-safe, no bitwise ops) @@ -111,107 +133,159 @@ float bayer2(vec2 c) { float bayer4(vec2 p) { vec2 c = mod(floor(p), 4.0); - vec2 hi = floor(c * 0.5); - vec2 lo = mod(c, 2.0); - return bayer2(lo) * 4.0 + bayer2(hi); + return bayer2(mod(c, 2.0)) * 4.0 + bayer2(floor(c * 0.5)); } float bayer8(vec2 p) { vec2 c = mod(floor(p), 8.0); - vec2 hi = floor(c * 0.5); - vec2 lo = mod(c, 2.0); - return (bayer4(hi) * 4.0 + bayer2(lo) + 0.5) / 64.0; + return (bayer4(floor(c * 0.5)) * 4.0 + bayer2(mod(c, 2.0)) + 0.5) / 64.0; } -float fireField(vec2 uv, float aspect, float t) { - float climb = mix(0.22, 0.82, clamp(u_height, 0.0, 1.0)); - - // Column space — sway as they rise - vec2 q = vec2(uv.x * aspect * 1.85, uv.y * 1.55 - t * 0.48); - q.x += sin(uv.y * 7.0 + t * 1.6) * 0.045 * (0.25 + uv.y); +float climbOf() { + return mix(0.18, 0.78, u_height) * u_ignite; +} - // Two-pass domain warp — curling tongues, not blobs - vec2 w1 = vec2( - fbm(q * 1.7 + vec2(t * 0.28, t * 0.22)), - fbm(q * 1.7 + vec2(-t * 0.24, t * 0.31) + 5.2) +// Temperature: below 0 is air, around 1 is the white-hot bed of the fire. +float temperature(vec2 uv, float aspect, float t, float plume) { + float x = uv.x * aspect; + float climb = max(climbOf(), 0.001); + float y = uv.y / climb; + + // Slow swells along the bed, so the flame line is never flat. + y /= mix(0.6, 1.3, fbm(vec2(x * 1.1 + 3.1, t * 0.16))); + // Under the pointer the fire stretches up toward it. + y /= 1.0 + plume * max(u_mouse.y / climb - 0.4, 0.3); + + // Turbulence rising through the flame: stretched tall so it reads as + // tongues, and swept sideways harder the higher it climbs. + // Few octaves on purpose: smooth, graphic tongues rather than ragged, + // photographic ones. + vec2 q = vec2(x * 4.4, uv.y * 1.5 - t * 1.1); + vec2 w = vec2( + fbm(q * vec2(0.5, 0.8) + vec2(0.0, -t * 0.3)), + fbm(q * 0.6 + vec2(5.2, 1.3 - t * 0.25)) ); - q += (w1 - 0.5) * 0.58; - vec2 w2 = vec2( - fbm(q * 3.4 - vec2(t * 0.52, 0.0)), - fbm(q * 3.4 + vec2(0.0, t * 0.44) + 2.7) - ); - q += (w2 - 0.5) * 0.26; - - float shape = fbm(q * 2.35); - float detail = fbm(q * 7.2 + vec2(0.0, t * 1.7)); - float flicker = 0.9 + 0.1 * sin(t * 13.0 + uv.x * 9.0 + shape * 6.0); - float field = (shape * 0.68 + detail * 0.32) * flicker; - - // Hotter, wider base; thinner breakaways as they climb - float taper = pow(clamp(1.0 - uv.y / max(climb, 0.05), 0.0, 1.0), 1.35); - float core = pow(clamp(1.0 - uv.y / max(climb * 0.42, 0.04), 0.0, 1.0), 2.1); - field = field * taper + core * shape * 0.38; + q += (w - 0.5) * vec2(2.4, 0.8) * (0.3 + y); + float body = fbm(q) - 0.5; + // Ridged: where the finer noise crosses its midpoint it peaks sharply, + // which curls the bands into licks instead of flat stripes. + float licks = 0.2 - abs(fbm(q * vec2(1.5, 2.0) + vec2(0.0, -t * 1.6)) - 0.5) * 2.0; + float turb = body * 0.8 + licks * 0.45; + + float T = 1.0 - y * 1.1 + turb * 2.6 * min(0.5 + y, 1.2); + // Nothing burns far above the tips, however the noise rolls. + return T - smoothstep(1.0, 1.7, y); +} - float md = length((uv - u_mouse) * vec2(aspect, 1.0)); - field += exp(-md * 6.8) * 0.28 * (0.55 + 0.45 * shape); +// Sparks: three parallax layers of cells scrolling up, one spark in a few. +float embers(vec2 uv, float aspect, float t, float plume) { + if (u_embers <= 0.0) return 0.0; + float climb = max(climbOf(), 0.05); + float sum = 0.0; + for (int i = 0; i < 3; i++) { + float fi = float(i); + float scale = 8.0 + fi * 7.0; + vec2 p = vec2(uv.x * aspect, uv.y) * scale; + float column = floor(p.x); + float lane = hash(vec2(column, fi * 7.1)); + p.y -= t * (0.34 - fi * 0.06) * scale * (0.7 + 0.6 * lane); + + vec2 id = floor(p); + vec2 h = hash2(id + fi * 17.3); + float rate = u_embers * (0.16 + fi * 0.07) * (1.0 + plume * 2.5); + if (h.x > rate) continue; + + vec2 c = 0.5 + (h - 0.5) * 0.45; + c.x += sin(t * (1.4 + h.y * 2.2) + h.x * 40.0) * 0.16; + vec2 d = fract(p) - c; + d.y *= 0.5; + float r = mix(0.07, 0.13, h.y) * (1.0 - fi * 0.22); + float spark = 1.0 - smoothstep(0.0, r, length(d)); + spark *= spark; + + float twinkle = 0.55 + 0.45 * sin(t * (5.0 + h.y * 9.0) + h.x * 90.0); + // Born in the flames, burnt out a little above them. + float life = 1.0 - smoothstep(climb * 0.5, climb * 2.6, uv.y); + life *= smoothstep(0.0, climb * 0.35, uv.y); + sum += spark * twinkle * life * (1.0 - fi * 0.25); + } + return min(sum, 1.0); +} - field *= smoothstep(0.0, 0.05, uv.y + 0.015); - field = smoothstep(0.22, 0.74, field); +// Flat cel bands, hottest innermost. 'hot' is the core pushed toward white. +vec3 bands(float T, vec3 hot) { + float w = 0.012; + vec3 c = mix(u_c0, u_c1, smoothstep(0.3 - w, 0.3 + w, T)); + c = mix(c, u_c2, smoothstep(0.6 - w, 0.6 + w, T)); + return mix(c, hot, smoothstep(1.02 - w, 1.02 + w, T)); +} - float intensity = mix(0.34, 1.18, clamp(u_intensity, 0.0, 1.0)); - return clamp(field * intensity, 0.0, 1.0); +vec3 linearToSrgb(vec3 c) { + c = clamp(c, 0.0, 1.0); + vec3 lo = c * 12.92; + vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; + return mix(lo, hi, step(vec3(0.0031308), c)); } void main() { float aspect = u_resolution.x / max(u_resolution.y, 1.0); - float t = u_time * u_speed; - float px = max(u_pixelSize, 1.0); - - vec2 sampleUv = gl_FragCoord.xy / u_resolution.xy; - vec2 cell = floor(gl_FragCoord.xy / px); - if (u_dither > 0.5) { - sampleUv = (cell + 0.5) * px / u_resolution.xy; - } - - float field = fireField(sampleUv, aspect, t); - - if (u_dither > 0.5) { - float bit = step(bayer8(cell), field); - field = bit * mix(0.45, 1.0, field); - } - - vec3 ember = u_c1; - vec3 flame = u_c2; - vec3 highlight = u_c3; - vec3 tint = mix(ember, flame, smoothstep(0.08, 0.48, field)); - tint = mix(tint, highlight, pow(smoothstep(0.42, 0.96, field), 1.55)); - - vec3 paper = u_dark < 0.5 - ? vec3(0.992, 0.986, 0.978) - : vec3(0.03, 0.032, 0.042); - // Light: watercolor on paper — more air, less orange stain - float cover = u_dither > 0.5 - ? field - : field * (u_dark < 0.5 ? 0.68 : 0.84); - vec3 col = mix(paper, tint, clamp(cover, 0.0, 1.0)); - if (u_dark < 0.5 && u_dither < 0.5) { - float haze = field * (1.0 - field) * 0.12; - col = mix(col, paper, haze); - } - - if (u_dither < 0.5) { - col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.01; - } - - gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); + float t = u_time; + + vec2 cell = floor(gl_FragCoord.xy / u_pixel); + vec2 uv = gl_FragCoord.xy / u_resolution; + if (u_dither > 0.5) uv = (cell + 0.5) * u_pixel / u_resolution; + + float dx = (uv.x - u_mouse.x) * aspect; + float plume = exp(-dx * dx * 26.0) * u_pointer; + + float T = temperature(uv, aspect, t, plume); + // Dither shakes the band edges by the Bayer threshold, so each boundary + // prints as an ordered-dot blend instead of a hard line. + float q = u_dither > 0.5 ? bayer8(cell) - 0.5 : 0.0; + T += q * 0.14; + float edge = u_dither > 0.5 ? 0.001 : 0.012; + float cover = smoothstep(0.0, edge, T) * mix(0.35, 1.0, u_intensity); + + float spark = embers(uv, aspect, t, plume); + spark = smoothstep(0.1 + q * 0.15, 0.3 + q * 0.15, spark); + + // The whole fire breathes a little, like a real one lighting a room. + float flicker = 0.86 + 0.14 * noise(vec2(t * 2.3, 4.7)); + float glow = exp(-uv.y / max(climbOf() * 1.7, 0.02) * 3.4) * flicker; + glow += plume * exp(-length(vec2(dx, uv.y - u_mouse.y)) * 5.0) * 0.5; + glow *= u_intensity; + if (u_dither > 0.5) glow = step(0.52 + q * 0.98, glow * 2.0) * 0.5; + + // On ink the core goes near white; on paper white would vanish into the + // page, so it stops at a pale gold. + vec3 hot = mix(u_c2, vec3(1.0), u_dark > 0.5 ? 0.75 : 0.4); + vec3 col = mix(u_base, mix(u_c0, u_c1, 0.4), glow * (u_dark > 0.5 ? 0.08 : 0.12)); + col = mix(col, bands(T, hot), cover); + col = mix(col, u_dark > 0.5 ? mix(u_c1, u_c2, 0.6) : u_c0, spark); + + vec3 srgb = linearToSrgb(col); + if (u_dither < 0.5) srgb += (hash(gl_FragCoord.xy + fract(t)) - 0.5) * 0.006; + gl_FragColor = vec4(clamp(srgb, 0.0, 1.0), 1.0); } ` function isDev() { - return typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + return ( + typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + ) } -function hexToRgb(hex: string): [number, number, number] { +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) +} + +/** Hex → linear sRGB, so the shader can add light the way light adds. */ +function hexToLinear(hex: string): [number, number, number] { const h = hex.replace("#", "").trim() const full = h.length === 3 @@ -221,8 +295,12 @@ function hexToRgb(hex: string): [number, number, number] { .join("") : h.padEnd(6, "0").slice(0, 6) const n = Number.parseInt(full, 16) - if (Number.isNaN(n)) return [0.77, 0.38, 0.18] - return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + const srgb = Number.isNaN(n) + ? [0.77, 0.38, 0.18] + : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + return srgb.map((c) => + c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + ) as [number, number, number] } /** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ @@ -262,39 +340,26 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { } /** - * Sparse 2D fire wash — tongues rise from the bottom behind UI. - * Theme-aware light / dusk. + * A wall of fire along the bottom edge: turbulent tongues colored by heat, + * sparks drifting up, and a warm glow that flickers with them. Theme-aware; + * pauses off-screen and in hidden tabs; holds a still frame under + * `prefers-reduced-motion`. */ export function createShaderFire( canvas: HTMLCanvasElement, initial: ShaderFireOptions = {} ): ShaderFireInstance | null { - let options: Required< - Pick< - ShaderFireOptions, - | "speed" - | "intensity" - | "height" - | "interactive" - | "dither" - | "pixelSize" - | "theme" - > - > & - ShaderFireOptions = { - speed: 0.55, - intensity: 0.55, - height: 0.45, + let options: ShaderFireOptions = { interactive: true, - dither: false, - pixelSize: 1, theme: "auto", ...initial, } - const mouse = { x: 0.5, y: 0.12 } - const targetMouse = { x: 0.5, y: 0.12 } - let reduce = false + const mouse = { x: 0.5, y: 0.5 } + const target = { x: 0.5, y: 0.5 } + // The plume eases in and out, so leaving the hero never snaps. + let pull = 0 + let pullTarget = 0 let dark = resolveDark(options.theme ?? "auto") options.onThemeChange?.(dark) @@ -349,47 +414,128 @@ export function createShaderFire( gl.enableVertexAttribArray(loc) gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) - const uResolution = gl.getUniformLocation(program, "u_resolution") - const uTime = gl.getUniformLocation(program, "u_time") - const uSpeed = gl.getUniformLocation(program, "u_speed") - const uIntensity = gl.getUniformLocation(program, "u_intensity") - const uHeight = gl.getUniformLocation(program, "u_height") - const uDark = gl.getUniformLocation(program, "u_dark") - const uC1 = gl.getUniformLocation(program, "u_c1") - const uC2 = gl.getUniformLocation(program, "u_c2") - const uC3 = gl.getUniformLocation(program, "u_c3") - const uMouse = gl.getUniformLocation(program, "u_mouse") - const uDither = gl.getUniformLocation(program, "u_dither") - const uPixelSize = gl.getUniformLocation(program, "u_pixelSize") - - let raf = 0 - let running = true - const start = performance.now() - let frozenTime = 0 - + const u = (name: string) => gl.getUniformLocation(program, name) + const uResolution = u("u_resolution") + const uTime = u("u_time") + const uIntensity = u("u_intensity") + const uHeight = u("u_height") + const uEmbers = u("u_embers") + const uIgnite = u("u_ignite") + const uDark = u("u_dark") + const uDither = u("u_dither") + const uPixel = u("u_pixel") + const uPointer = u("u_pointer") + const uMouse = u("u_mouse") + const uStops = [u("u_c0"), u("u_c1"), u("u_c2")] + const uBase = u("u_base") + + // 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 const resize = () => { const parent = canvas.parentElement if (!parent) return - const dpr = Math.min(window.devicePixelRatio || 1, 2) const w = parent.clientWidth const h = parent.clientHeight if (w <= 0 || h <= 0) return + 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)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` gl.viewport(0, 0, canvas.width, canvas.height) + wake() } - resize() - const ro = new ResizeObserver(resize) - if (canvas.parentElement) ro.observe(canvas.parentElement) + let colorsKey = "" + const syncColors = () => { + const source = options.colors?.length + ? options.colors + : dark + ? DARK_COLORS + : LIGHT_COLORS + const key = `${source.join()}|${dark}` + if (key === colorsKey) return + colorsKey = key + uStops.forEach((loc, i) => { + gl.uniform3fv(loc, hexToLinear(source[i % source.length]!)) + }) + gl.uniform3fv(uBase, hexToLinear(dark ? DARK_BASE : LIGHT_BASE)) + } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - const onReduce = () => { - reduce = mqReduce.matches + let onScreen = true + let raf = 0 + let last = 0 + let clock = 0 + // Seconds since the fire first caught; drives the ignite. + let lit = mqReduce.matches ? 10 : 0 + + const moving = () => + onScreen && !mqReduce.matches && document.visibilityState !== "hidden" + + function wake() { + if (raf) return + last = performance.now() + raf = requestAnimationFrame(tick) } - onReduce() + + // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside. + const tick = (now: number) => { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const live = moving() + if (live) { + clock += dt + lit += dt + } else if (mqReduce.matches) { + lit = 10 + } + + mouse.x += (target.x - mouse.x) * 0.08 + mouse.y += (target.y - mouse.y) * 0.08 + pull += (pullTarget - pull) * 0.05 + + syncColors() + const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4) + const ignite = 1 - (1 - Math.min(lit / 1.1, 1)) ** 3 + const px = Math.max(1, numberOr(options.pixelSize, 1, 1, 64)) * dpr + gl.uniform2f(uResolution, canvas.width, canvas.height) + gl.uniform1f(uTime, clock * speed) + gl.uniform1f( + uIntensity, + numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1) + ) + gl.uniform1f(uHeight, numberOr(options.height, DEFAULT_HEIGHT, 0, 1)) + gl.uniform1f(uEmbers, numberOr(options.embers, DEFAULT_EMBERS, 0, 1)) + gl.uniform1f(uIgnite, Math.max(ignite, 0.001)) + gl.uniform1f(uDark, dark ? 1 : 0) + gl.uniform1f(uDither, options.dither ? 1 : 0) + gl.uniform1f(uPixel, options.dither ? px : dpr) + gl.uniform1f(uPointer, options.interactive === false ? 0 : pull) + gl.uniform2f(uMouse, mouse.x, mouse.y) + gl.drawArrays(gl.TRIANGLES, 0, 6) + + const settling = + Math.abs(pull - pullTarget) > 0.002 || + Math.abs(target.x - mouse.x) > 0.001 || + Math.abs(target.y - mouse.y) > 0.001 + if (live || (settling && !mqReduce.matches)) { + raf = requestAnimationFrame(tick) + } + } + + 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 = () => { @@ -397,6 +543,7 @@ export function createShaderFire( if (next === dark) return dark = next options.onThemeChange?.(dark) + wake() } const mo = new MutationObserver(syncTheme) mo.observe(document.documentElement, { @@ -407,70 +554,37 @@ export function createShaderFire( mqDark.addEventListener("change", syncTheme) const onMove = (e: PointerEvent) => { - if (!options.interactive) return - const parent = canvas.parentElement - if (!parent) return - const rect = parent.getBoundingClientRect() + if (options.interactive === false) return + const rect = canvas.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return - targetMouse.x = (e.clientX - rect.left) / rect.width - targetMouse.y = 1 - (e.clientY - rect.top) / rect.height - } - window.addEventListener("pointermove", onMove, { passive: true }) - - const tick = (now: number) => { - if (!running) return - - if (reduce) { - if (frozenTime === 0) frozenTime = (now - start) / 1000 - } else { - frozenTime = 0 + 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 + pullTarget = inside ? 1 : 0 + if (inside) { + target.x = x + target.y = y } - - const time = reduce ? frozenTime : (now - start) / 1000 - const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS) - const palette = [0, 1, 2].map((i) => - hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!) - ) - - mouse.x += (targetMouse.x - mouse.x) * 0.05 - mouse.y += (targetMouse.y - mouse.y) * 0.05 - - const dpr = Math.min(window.devicePixelRatio || 1, 2) - const px = Math.max(1, options.pixelSize ?? 1) * dpr - - gl.uniform2f(uResolution, canvas.width, canvas.height) - gl.uniform1f(uTime, time) - gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.55)) - gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.55))) - gl.uniform1f(uHeight, Math.min(1, Math.max(0, options.height ?? 0.45))) - gl.uniform1f(uDark, dark ? 1 : 0) - gl.uniform1f(uDither, options.dither ? 1 : 0) - gl.uniform1f(uPixelSize, px) - gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2]) - gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2]) - gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2]) - gl.uniform2f( - uMouse, - options.interactive ? mouse.x : 0.5, - options.interactive ? mouse.y : 0.12 - ) - - gl.drawArrays(gl.TRIANGLES, 0, 6) - raf = requestAnimationFrame(tick) + wake() } + window.addEventListener("pointermove", onMove, { passive: true }) - raf = requestAnimationFrame(tick) + resize() + wake() return { setOptions(next) { options = { ...options, ...next } syncTheme() + wake() }, destroy() { - running = false 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) diff --git a/registry/shader-fire/shader-fire.svelte b/registry/shader-fire/shader-fire.svelte index 93969ed..82cdeff 100644 --- a/registry/shader-fire/shader-fire.svelte +++ b/registry/shader-fire/shader-fire.svelte @@ -6,6 +6,10 @@ import { createShaderFire, DARK_FALLBACK, + DEFAULT_EMBERS, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, type ShaderFireInstance, @@ -23,9 +27,10 @@ let { class: className = "", colors, - speed = 0.55, - intensity = 0.55, - height = 0.45, + speed = DEFAULT_SPEED, + intensity = DEFAULT_INTENSITY, + height = DEFAULT_HEIGHT, + embers = DEFAULT_EMBERS, interactive = true, dither = false, pixelSize = 1, @@ -60,6 +65,7 @@ speed, intensity, height, + embers, interactive, dither, pixelSize, @@ -86,6 +92,7 @@ speed, intensity, height, + embers, interactive, dither, pixelSize, @@ -99,8 +106,8 @@ diff --git a/registry/shader-fire/shader-fire.tsx b/registry/shader-fire/shader-fire.tsx index 5fae2f1..e2f62b0 100644 --- a/registry/shader-fire/shader-fire.tsx +++ b/registry/shader-fire/shader-fire.tsx @@ -7,6 +7,10 @@ import { cn } from "@/lib/utils" import { createShaderFire, DARK_FALLBACK, + DEFAULT_EMBERS, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, type ShaderFireInstance, @@ -16,6 +20,10 @@ import { export { DARK_COLORS, DARK_FALLBACK, + DEFAULT_EMBERS, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_COLORS, LIGHT_FALLBACK, } from "./shader-fire-vanilla" @@ -30,15 +38,16 @@ export type ShaderFireProps = Omit & { } /** - * Sparse 2D fire wash — tongues rise from the bottom behind UI. - * Theme-aware light / dusk. + * Cel-shaded flames licking up from the bottom edge, with sparks drifting + * above them. Heat reaches for the pointer. Theme-aware. */ export function ShaderFire({ className, colors, - speed = 0.55, - intensity = 0.55, - height = 0.45, + speed = DEFAULT_SPEED, + intensity = DEFAULT_INTENSITY, + height = DEFAULT_HEIGHT, + embers = DEFAULT_EMBERS, interactive = true, dither = false, pixelSize = 1, @@ -72,6 +81,7 @@ export function ShaderFire({ speed, intensity, height, + embers, interactive, dither, pixelSize, @@ -92,13 +102,24 @@ export function ShaderFire({ speed, intensity, height, + embers, interactive, dither, pixelSize, theme, onThemeChange: setIsDark, }) - }, [colors, speed, intensity, height, interactive, dither, pixelSize, theme]) + }, [ + colors, + speed, + intensity, + height, + embers, + interactive, + dither, + pixelSize, + theme, + ]) const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK @@ -107,7 +128,7 @@ export function ShaderFire({ data-slot="shader-fire" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]", className )} style={{ @@ -115,7 +136,12 @@ export function ShaderFire({ backgroundImage: fallback.backgroundImage, }} > - + {/* The canvas carries the radius itself: a WebGL layer can slip past a + parent's rounded clip on some GPUs. */} +
) } diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index ede6b3f..c708d21 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -171,7 +171,7 @@ Categories match `content/docs/components/meta.json`. | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | -| `shader-fire` | Shaders | Sparse fire tongues rising from the bottom | React + Svelte | +| `shader-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | | `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | | `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | React + Svelte | | `live-orb` | Characters | Lit sphere; eyes follow the pointer | React + Svelte | diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 167c924..e975d29 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -151,10 +151,11 @@ type ShaderGradientProps = { ```ts type ShaderFireProps = { - colors?: string[] // ember, flame, highlight - speed?: number // 0.55 - intensity?: number // 0.55 + colors?: string[] // ember, flame, core + speed?: number // 1 + intensity?: number // 1 height?: number // 0.45 + embers?: number // 0.6 interactive?: boolean // true dither?: boolean // false pixelSize?: number // 1 diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 504ab6c..f6e2dbe 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -145,13 +145,13 @@ Pitfalls: custom `colors` stick; they do not cross-fade into the dark palette. S ## shader-fire -Shader. Sparse tongues from the bottom edge. Ember / flame / highlight. +Shader. Cel-shaded flames lick up from the bottom edge, sparks drift above. Ember / flame / core. -Best fit: heat behind a hero. `dither` turns the wash into Bayer pixels. +Best fit: fire along the floor of a hero. `dither` prints the bands as Bayer pixels. Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). -Deps: none. Key props: `colors`, `speed` (0.55), `intensity` (0.55), `height` (0.45), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. +Deps: none. Key props: `colors`, `speed` (1), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. ```tsx "use client" @@ -167,7 +167,7 @@ export function Hero() { } ``` -Pitfalls: `interactive` adds heat under the pointer. `dither` defaults off. Custom `colors` do not follow theme. Light `#9C3A24 #C96A32 #E6C4A0`. Dark `#A33A18 #D4682A #E8B45A`. +Pitfalls: `interactive` makes a plume of flame reach up toward the pointer. `dither` defaults off. `embers={0}` turns sparks off. Custom `colors` do not follow theme. Light `#D8341A #F9731E #FFBA3A`. Dark `#B4200A #FF6512 #FFC04A`. ## shader-sky diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index 11ff369..c4a4e95 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -33,7 +33,7 @@ Swap the component, keep the shell: | Brief | Component | Extra props | | --- | --- | --- | | Fire along the floor | `ShaderFire` | `height={0.45}` | -| Pixel fire | `ShaderFire` | `dither pixelSize={1}` | +| Pixel fire | `ShaderFire` | `dither pixelSize={3}` | | Blue sky | `ShaderSky` | leave `colors` unset | | Rain / dusk sky | `ShaderSky` | `theme="dark"` or `html.dark` | | Window glass | `ShaderSky` | `glass glassSize={7}` | From 9c7fe5c127c679a62eca85a8ac88ea17c9317546 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 13:21:22 +0530 Subject: [PATCH 27/49] Rename Shader Fire to Shader Anime Fire. The component, its files, exports, registry items and docs slug all become shader-anime-fire / ShaderAnimeFire, to match the cel-shaded look. The old docs URL redirects permanently to the new one, and Dithered 404 no longer compares its soft fire to this one. --- .../{shader-fire.mdx => shader-anime-fire.mdx} | 0 .../image.png | Bin public/r/shader-fire-svelte.json | 16 ---------------- public/r/shader-fire.json | 15 --------------- .../registry.json | 0 .../shader-anime-fire-demo.tsx} | 0 .../shader-anime-fire-vanilla.ts} | 0 .../shader-anime-fire.svelte} | 0 .../shader-anime-fire.tsx} | 0 9 files changed, 31 deletions(-) rename content/docs/components/{shader-fire.mdx => shader-anime-fire.mdx} (100%) rename public/og/docs/components/{shader-fire => shader-anime-fire}/image.png (100%) delete mode 100644 public/r/shader-fire-svelte.json delete mode 100644 public/r/shader-fire.json rename registry/{shader-fire => shader-anime-fire}/registry.json (100%) rename registry/{shader-fire/shader-fire-demo.tsx => shader-anime-fire/shader-anime-fire-demo.tsx} (100%) rename registry/{shader-fire/shader-fire-vanilla.ts => shader-anime-fire/shader-anime-fire-vanilla.ts} (100%) rename registry/{shader-fire/shader-fire.svelte => shader-anime-fire/shader-anime-fire.svelte} (100%) rename registry/{shader-fire/shader-fire.tsx => shader-anime-fire/shader-anime-fire.tsx} (100%) diff --git a/content/docs/components/shader-fire.mdx b/content/docs/components/shader-anime-fire.mdx similarity index 100% rename from content/docs/components/shader-fire.mdx rename to content/docs/components/shader-anime-fire.mdx diff --git a/public/og/docs/components/shader-fire/image.png b/public/og/docs/components/shader-anime-fire/image.png similarity index 100% rename from public/og/docs/components/shader-fire/image.png rename to public/og/docs/components/shader-anime-fire/image.png diff --git a/public/r/shader-fire-svelte.json b/public/r/shader-fire-svelte.json deleted file mode 100644 index 828e28e..0000000 --- a/public/r/shader-fire-svelte.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "shader-fire-svelte", - "title": "Shader Fire", - "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", - "dependencies": [], - "files": [ - { - "path": "registry/shader-fire/shader-fire.svelte", - "content": "\n\n\n\n\n \n
\n", - "type": "registry:file", - "target": "src/lib/components/ui/shader-fire.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/shader-fire.json b/public/r/shader-fire.json deleted file mode 100644 index aef5103..0000000 --- a/public/r/shader-fire.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "shader-fire", - "title": "Shader Fire", - "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", - "files": [ - { - "path": "registry/shader-fire/shader-fire.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderFireOptions = {\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 `1`. */\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 /** How many sparks drift up, 0–1. `0` turns them off. Default `0.6`. */\n embers?: 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?: ShaderFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderFireInstance = {\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 = 1\nexport const DEFAULT_INTENSITY = 1\nexport const DEFAULT_HEIGHT = 0.45\nexport const DEFAULT_EMBERS = 0.6\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_embers;\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' hashes — no sin(), so they stay 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 hash2(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\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// Sparks: three parallax layers of cells scrolling up, one spark in a few.\nfloat embers(vec2 uv, float aspect, float t, float plume) {\n if (u_embers <= 0.0) return 0.0;\n float climb = max(climbOf(), 0.05);\n float sum = 0.0;\n for (int i = 0; i < 3; i++) {\n float fi = float(i);\n float scale = 8.0 + fi * 7.0;\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float column = floor(p.x);\n float lane = hash(vec2(column, fi * 7.1));\n p.y -= t * (0.34 - fi * 0.06) * scale * (0.7 + 0.6 * lane);\n\n vec2 id = floor(p);\n vec2 h = hash2(id + fi * 17.3);\n float rate = u_embers * (0.16 + fi * 0.07) * (1.0 + plume * 2.5);\n if (h.x > rate) continue;\n\n vec2 c = 0.5 + (h - 0.5) * 0.45;\n c.x += sin(t * (1.4 + h.y * 2.2) + h.x * 40.0) * 0.16;\n vec2 d = fract(p) - c;\n d.y *= 0.5;\n float r = mix(0.07, 0.13, h.y) * (1.0 - fi * 0.22);\n float spark = 1.0 - smoothstep(0.0, r, length(d));\n spark *= spark;\n\n float twinkle = 0.55 + 0.45 * sin(t * (5.0 + h.y * 9.0) + h.x * 90.0);\n // Born in the flames, burnt out a little above them.\n float life = 1.0 - smoothstep(climb * 0.5, climb * 2.6, uv.y);\n life *= smoothstep(0.0, climb * 0.35, uv.y);\n sum += spark * twinkle * life * (1.0 - fi * 0.25);\n }\n return min(sum, 1.0);\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 float spark = embers(uv, aspect, t, plume);\n spark = smoothstep(0.1 + q * 0.15, 0.3 + q * 0.15, spark);\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 col = mix(col, u_dark > 0.5 ? mix(u_c1, u_c2, 0.6) : u_c0, spark);\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: ShaderFireTheme): 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 \"ShaderFire: 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 fire along the bottom edge: turbulent tongues colored by heat,\n * sparks drifting up, and a warm glow that flickers with them. Theme-aware;\n * pauses off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`.\n */\nexport function createShaderFire(\n canvas: HTMLCanvasElement,\n initial: ShaderFireOptions = {}\n): ShaderFireInstance | null {\n let options: ShaderFireOptions = {\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uIntensity = u(\"u_intensity\")\n const uHeight = u(\"u_height\")\n const uEmbers = u(\"u_embers\")\n const uIgnite = u(\"u_ignite\")\n const uDark = u(\"u_dark\")\n const uDither = u(\"u_dither\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n const uBase = u(\"u_base\")\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 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 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 if (raf) 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 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(uEmbers, numberOr(options.embers, DEFAULT_EMBERS, 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 resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderFireProps = Omit & {\n className?: string\n}\n\n/**\n * Cel-shaded flames licking up from the bottom edge, with sparks drifting\n * above them. Heat reaches for the pointer. Theme-aware.\n */\nexport function ShaderFire({\n className,\n colors,\n speed = DEFAULT_SPEED,\n intensity = DEFAULT_INTENSITY,\n height = DEFAULT_HEIGHT,\n embers = DEFAULT_EMBERS,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderFireProps) {\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 = createShaderFire(canvas, {\n colors,\n speed,\n intensity,\n height,\n embers,\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 embers,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n intensity,\n height,\n embers,\n interactive,\n dither,\n pixelSize,\n theme,\n ])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* The canvas carries the radius itself: a WebGL layer can slip past a\n parent's rounded clip on some GPUs. */}\n \n
\n )\n}\n", - "type": "registry:ui", - "target": "components/ui/shader-fire.tsx" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/registry/shader-fire/registry.json b/registry/shader-anime-fire/registry.json similarity index 100% rename from registry/shader-fire/registry.json rename to registry/shader-anime-fire/registry.json diff --git a/registry/shader-fire/shader-fire-demo.tsx b/registry/shader-anime-fire/shader-anime-fire-demo.tsx similarity index 100% rename from registry/shader-fire/shader-fire-demo.tsx rename to registry/shader-anime-fire/shader-anime-fire-demo.tsx diff --git a/registry/shader-fire/shader-fire-vanilla.ts b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts similarity index 100% rename from registry/shader-fire/shader-fire-vanilla.ts rename to registry/shader-anime-fire/shader-anime-fire-vanilla.ts diff --git a/registry/shader-fire/shader-fire.svelte b/registry/shader-anime-fire/shader-anime-fire.svelte similarity index 100% rename from registry/shader-fire/shader-fire.svelte rename to registry/shader-anime-fire/shader-anime-fire.svelte diff --git a/registry/shader-fire/shader-fire.tsx b/registry/shader-anime-fire/shader-anime-fire.tsx similarity index 100% rename from registry/shader-fire/shader-fire.tsx rename to registry/shader-anime-fire/shader-anime-fire.tsx From 3aa4868c27d715f686df7b77e4c8538b8efea02a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 13:21:25 +0530 Subject: [PATCH 28/49] Slow Shader Anime Fire's default speed to 0.6. --- .cursor/skills/23rd/SKILL.md | 12 +++---- .cursor/skills/23rd/apis.md | 6 ++-- .cursor/skills/23rd/components.md | 12 +++---- .cursor/skills/23rd/recipes.md | 10 +++--- content/docs/components/shader-anime-fire.mdx | 32 +++++++++---------- public/r/shader-anime-fire-svelte.json | 16 ++++++++++ public/r/shader-anime-fire.json | 15 +++++++++ .../shader-anime-fire-vanilla.ts | 28 ++++++++-------- skills/23rd/SKILL.md | 12 +++---- skills/23rd/apis.md | 6 ++-- skills/23rd/components.md | 12 +++---- skills/23rd/recipes.md | 10 +++--- 12 files changed, 101 insertions(+), 70 deletions(-) create mode 100644 public/r/shader-anime-fire-svelte.json create mode 100644 public/r/shader-anime-fire.json diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index c708d21..73d9b23 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -92,7 +92,7 @@ flowchart TD pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} atmo -->|Quiet wash| sg[shader-gradient] - atmo -->|Fire from the bottom| sf[shader-fire] + atmo -->|Fire from the bottom| sf[shader-anime-fire] atmo -->|Sky or rain, optional glass| ss[shader-sky] atmo -->|ASCII mouse trails| af[ascii-fluid] atmo -->|Hyperspace streaks, scroll warp| rl[radiant-lines] @@ -109,8 +109,8 @@ Disambiguation that agents get wrong: | --- | --- | --- | | Color picker, swatch, hex, hue, alpha, eyedropper | `gooey-color-picker` | Any shader. Shaders are not controls. | | Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | -| Fire, embers, heat under a hero | `shader-fire` | `dithered-404` unless the page is a 404 | -| 404 that burns under the cursor | `dithered-404` | `shader-fire` | +| Fire, embers, heat under a hero | `shader-anime-fire` | `dithered-404` unless the page is a 404 | +| 404 that burns under the cursor | `dithered-404` | `shader-anime-fire` | | Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | 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` | @@ -132,7 +132,7 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `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`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `RadiantLines` is transparent and warps with scroll. Inside an overflow div, pass the scroller (`containerRef` in React, `container` in Svelte) and make the canvas `sticky top-0 h-svh`. Omit the scroller to use the window. @@ -153,7 +153,7 @@ That matches shadcn / `next-themes` with `attribute="class"`. There is no shared Exceptions: -- Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. +- Passing `colors` to `ShaderGradient`, `ShaderAnimeFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. - `PhosphorScore` canvas is transparent in both themes; the parent paints the background. - `Folio` tilt peaks at an internal 16°. It is not a prop. The lean follows wheel speed, so trackpads and mice lean alike. Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. @@ -171,7 +171,7 @@ Categories match `content/docs/components/meta.json`. | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | -| `shader-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | +| `shader-anime-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | | `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | | `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | React + Svelte | | `live-orb` | Characters | Lit sphere; eyes follow the pointer | React + Svelte | diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index e975d29..73382bc 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -147,12 +147,12 @@ type ShaderGradientProps = { `LIGHT_FALLBACK` and `DARK_FALLBACK` are CSS background stacks used when WebGL is unavailable. Palette line in the engine: `options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)`. -## shader-fire +## shader-anime-fire ```ts -type ShaderFireProps = { +type ShaderAnimeFireProps = { colors?: string[] // ember, flame, core - speed?: number // 1 + speed?: number // 0.6 intensity?: number // 1 height?: number // 0.45 embers?: number // 0.6 diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index f6e2dbe..6f085ad 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -44,7 +44,7 @@ Background. Two vertical staves. Notes fall, bloom at the playhead, then flare. Best fit: a music, broadcast, or terminal hero that should feel like a CRT score. -Not this: fire (`shader-fire`), sky (`shader-sky`), a footer. +Not this: fire (`shader-anime-fire`), sky (`shader-sky`), a footer. Deps: none. Key props: `color`, `glow` (50, range 0–100), `speed` (1.35 beats/s), `density` (1), `sway` (true), `seed` (23), `theme`. @@ -143,7 +143,7 @@ export function Hero() { Pitfalls: custom `colors` stick; they do not cross-fade into the dark palette. Stock light `#7CB4E0 #B4D8C4 #EFE4BC #D2D7EC`. Stock dark `#3A6FA0 #2F6B52 #8A6B32 #4A4D7A`. If WebGL fails, a CSS `LIGHT_FALLBACK` / `DARK_FALLBACK` radial stack is used. `interactive` eases the wash toward the pointer. -## shader-fire +## shader-anime-fire Shader. Cel-shaded flames lick up from the bottom edge, sparks drift above. Ember / flame / core. @@ -151,16 +151,16 @@ Best fit: fire along the floor of a hero. `dither` prints the bands as Bayer pix Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). -Deps: none. Key props: `colors`, `speed` (1), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. +Deps: none. Key props: `colors`, `speed` (0.6), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. ```tsx "use client" -import { ShaderFire } from "@/components/ui/shader-fire" +import { ShaderAnimeFire } from "@/components/ui/shader-anime-fire" export function Hero() { return (
- +
Your content
) @@ -281,7 +281,7 @@ Page. A Bayer-dithered “404” burned by a fireball cursor into embers and smo Best fit: the not-found route, full viewport. -Not this: decorative fire behind a normal hero (`shader-fire`). Set `dither={false}` only when you want the soft fire on the same 404 glyph. +Not this: decorative fire behind a normal hero (`shader-anime-fire`). Set `dither={false}` only when you want the soft fire on the same 404 glyph. Deps: none. Key props: `color`, `pixelSize` (4), `brush` (28), `interactive` (true), `dither` (true), `theme`. diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index c4a4e95..42d5bb9 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -32,8 +32,8 @@ Swap the component, keep the shell: | Brief | Component | Extra props | | --- | --- | --- | -| Fire along the floor | `ShaderFire` | `height={0.45}` | -| Pixel fire | `ShaderFire` | `dither pixelSize={3}` | +| Fire along the floor | `ShaderAnimeFire` | `height={0.45}` | +| Pixel fire | `ShaderAnimeFire` | `dither pixelSize={3}` | | Blue sky | `ShaderSky` | leave `colors` unset | | Rain / dusk sky | `ShaderSky` | `theme="dark"` or `html.dark` | | Window glass | `ShaderSky` | `glass glassSize={7}` | @@ -76,7 +76,7 @@ Svelte: `bind:this={scroller}` and `container={scroller}`. ## 404 -Use `dithered-404` on the not-found route. Do not also mount `shader-fire`. +Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. ```tsx "use client" @@ -97,7 +97,7 @@ export function NotFound() { } ``` -`dither={false}` keeps the 404 glyph and switches to soft fire. It does not turn the page into `shader-fire`. +`dither={false}` keeps the 404 glyph and switches to soft fire. It does not turn the page into `shader-anime-fire`. ## Rubber overscroll @@ -224,7 +224,7 @@ export function Brand() { - `ShaderGradient` as a color control, or `GooeyColorPicker` as a page background. - `StretchyFooter` and `Folio` both wrapping the same scroll. Pick one owner of the scroll. -- Two of `ShaderGradient`, `ShaderFire`, `ShaderSky`, `AsciiFluid` in the same viewport. +- Two of `ShaderGradient`, `ShaderAnimeFire`, `ShaderSky`, `AsciiFluid` in the same viewport. - Content as a sibling with no `z-10`, so the canvas eats clicks. Set `pointer-events-none` on the canvas wrapper when the field is decorative (`LogoBurst` click-to-replay is the exception). - Assuming `theme="auto"` recolors a shader after you passed `colors`. It will not. - Adding `variant` to anything but `LiveOrb`. diff --git a/content/docs/components/shader-anime-fire.mdx b/content/docs/components/shader-anime-fire.mdx index 97d304d..22d0334 100644 --- a/content/docs/components/shader-anime-fire.mdx +++ b/content/docs/components/shader-anime-fire.mdx @@ -1,23 +1,23 @@ --- -title: Shader Fire +title: Shader Anime Fire description: Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above them, and the fire reaches for the pointer. --- -import { ShaderFireDemo } from "@/registry/shader-fire/shader-fire-demo" +import { ShaderAnimeFireDemo } from "@/registry/shader-anime-fire/shader-anime-fire-demo" Flat, graphic fire rather than a photo of one. Four crisp bands (ember, flame, core, and a hot center) curl up from the bottom edge, sparks drift up past the copy, and a warm glow flickers under it all. Move the pointer over it and a plume of flame reaches up toward it. Toggle **Dither** to print the same bands as ordered Bayer pixels. Press `d` to switch theme. ## Preview - + ## Installation - + Or install straight from the public GitHub source registry: - + ## Usage @@ -28,12 +28,12 @@ Pin it behind a relatively positioned section. Keep content in a later stacking ```tsx "use client" -import { ShaderFire } from "@/components/ui/shader-fire" +import { ShaderAnimeFire } from "@/components/ui/shader-anime-fire" export function Hero() { return (
- +

New @@ -53,11 +53,11 @@ export function Hero() { ```svelte

- +

New @@ -79,14 +79,14 @@ Force a palette if the parent theme should not drive it: ```tsx - - + + ``` ```svelte - - + + ``` @@ -96,12 +96,12 @@ Print the same bands as ordered Bayer pixels: ```tsx - + ``` ```svelte - + ``` @@ -111,7 +111,7 @@ Print the same bands as ordered Bayer pixels: | Prop | Type | Default | | --- | --- | --- | | `colors` | `string[]` | ember / flame / core | -| `speed` | `number` | `1` | +| `speed` | `number` | `0.6` | | `intensity` | `number` | `1` | | `height` | `number` | `0.45` | | `embers` | `number` | `0.6` | diff --git a/public/r/shader-anime-fire-svelte.json b/public/r/shader-anime-fire-svelte.json new file mode 100644 index 0000000..db46152 --- /dev/null +++ b/public/r/shader-anime-fire-svelte.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "shader-anime-fire-svelte", + "title": "Shader Anime Fire", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", + "dependencies": [], + "files": [ + { + "path": "registry/shader-anime-fire/shader-anime-fire.svelte", + "content": "\n\n\n\n\n \n

\n", + "type": "registry:file", + "target": "src/lib/components/ui/shader-anime-fire.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/shader-anime-fire.json b/public/r/shader-anime-fire.json new file mode 100644 index 0000000..429d045 --- /dev/null +++ b/public/r/shader-anime-fire.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "shader-anime-fire", + "title": "Shader Anime Fire", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", + "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 /** How many sparks drift up, 0–1. `0` turns them off. Default `0.6`. */\n embers?: 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\nexport const DEFAULT_EMBERS = 0.6\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_embers;\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' hashes — no sin(), so they stay 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 hash2(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\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// Sparks: three parallax layers of cells scrolling up, one spark in a few.\nfloat embers(vec2 uv, float aspect, float t, float plume) {\n if (u_embers <= 0.0) return 0.0;\n float climb = max(climbOf(), 0.05);\n float sum = 0.0;\n for (int i = 0; i < 3; i++) {\n float fi = float(i);\n float scale = 8.0 + fi * 7.0;\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float column = floor(p.x);\n float lane = hash(vec2(column, fi * 7.1));\n p.y -= t * (0.34 - fi * 0.06) * scale * (0.7 + 0.6 * lane);\n\n vec2 id = floor(p);\n vec2 h = hash2(id + fi * 17.3);\n float rate = u_embers * (0.16 + fi * 0.07) * (1.0 + plume * 2.5);\n if (h.x > rate) continue;\n\n vec2 c = 0.5 + (h - 0.5) * 0.45;\n c.x += sin(t * (1.4 + h.y * 2.2) + h.x * 40.0) * 0.16;\n vec2 d = fract(p) - c;\n d.y *= 0.5;\n float r = mix(0.07, 0.13, h.y) * (1.0 - fi * 0.22);\n float spark = 1.0 - smoothstep(0.0, r, length(d));\n spark *= spark;\n\n float twinkle = 0.55 + 0.45 * sin(t * (5.0 + h.y * 9.0) + h.x * 90.0);\n // Born in the flames, burnt out a little above them.\n float life = 1.0 - smoothstep(climb * 0.5, climb * 2.6, uv.y);\n life *= smoothstep(0.0, climb * 0.35, uv.y);\n sum += spark * twinkle * life * (1.0 - fi * 0.25);\n }\n return min(sum, 1.0);\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 float spark = embers(uv, aspect, t, plume);\n spark = smoothstep(0.1 + q * 0.15, 0.3 + q * 0.15, spark);\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 col = mix(col, u_dark > 0.5 ? mix(u_c1, u_c2, 0.6) : u_c0, spark);\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 fire along the bottom edge: turbulent tongues colored by heat,\n * sparks drifting up, and a warm glow that flickers with them. Theme-aware;\n * pauses off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`.\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uIntensity = u(\"u_intensity\")\n const uHeight = u(\"u_height\")\n const uEmbers = u(\"u_embers\")\n const uIgnite = u(\"u_ignite\")\n const uDark = u(\"u_dark\")\n const uDither = u(\"u_dither\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n const uBase = u(\"u_base\")\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 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 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 if (raf) 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 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(uEmbers, numberOr(options.embers, DEFAULT_EMBERS, 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 resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderAnimeFireProps = Omit & {\n className?: string\n}\n\n/**\n * Cel-shaded flames licking up from the bottom edge, with sparks drifting\n * above them. 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 embers = DEFAULT_EMBERS,\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 embers,\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 embers,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n intensity,\n height,\n embers,\n interactive,\n dither,\n pixelSize,\n theme,\n ])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* The canvas carries the radius itself: a WebGL layer can slip past a\n parent's rounded clip on some GPUs. */}\n \n
\n )\n}\n", + "type": "registry:ui", + "target": "components/ui/shader-anime-fire.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/registry/shader-anime-fire/shader-anime-fire-vanilla.ts b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts index 06eeaa4..ff8eb6d 100644 --- a/registry/shader-anime-fire/shader-anime-fire-vanilla.ts +++ b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts @@ -1,12 +1,12 @@ -export type ShaderFireTheme = "light" | "dark" | "auto" +export type ShaderAnimeFireTheme = "light" | "dark" | "auto" -export type ShaderFireOptions = { +export type ShaderAnimeFireOptions = { /** * Three colors, coolest to hottest: ember, flame, core. In dark mode the * densest heat still burns past the core toward white. */ colors?: string[] - /** How fast the flames rise. Default `1`. */ + /** How fast the flames rise. Default `0.6`. */ speed?: number /** Opacity of the fire and its glow, 0–1. Default `1`. */ intensity?: number @@ -24,13 +24,13 @@ export type ShaderFireOptions = { * Palette mode. Default `auto` follows shadcn / next-themes * (`html.dark` class) so light and dark swap with the site theme. */ - theme?: ShaderFireTheme + theme?: ShaderAnimeFireTheme /** Fires whenever resolved dark mode changes (CSS fallback). */ onThemeChange?: (dark: boolean) => void } -export type ShaderFireInstance = { - setOptions: (options: Partial) => void +export type ShaderAnimeFireInstance = { + setOptions: (options: Partial) => void destroy: () => void } @@ -41,7 +41,7 @@ export const DARK_COLORS = ["#B4200A", "#FF6512", "#FFC04A"] const LIGHT_BASE = "#FCFBF9" const DARK_BASE = "#08090C" -export const DEFAULT_SPEED = 1 +export const DEFAULT_SPEED = 0.6 export const DEFAULT_INTENSITY = 1 export const DEFAULT_HEIGHT = 0.45 export const DEFAULT_EMBERS = 0.6 @@ -315,7 +315,7 @@ export function isDarkTheme(): boolean { return window.matchMedia("(prefers-color-scheme: dark)").matches } -export function resolveDark(theme: ShaderFireTheme): boolean { +export function resolveDark(theme: ShaderAnimeFireTheme): boolean { if (theme === "dark") return true if (theme === "light") return false return isDarkTheme() @@ -329,7 +329,7 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { if (isDev()) { console.warn( - "ShaderFire: shader failed to compile\n", + "ShaderAnimeFire: shader failed to compile\n", gl.getShaderInfoLog(shader) ) } @@ -345,11 +345,11 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { * pauses off-screen and in hidden tabs; holds a still frame under * `prefers-reduced-motion`. */ -export function createShaderFire( +export function createShaderAnimeFire( canvas: HTMLCanvasElement, - initial: ShaderFireOptions = {} -): ShaderFireInstance | null { - let options: ShaderFireOptions = { + initial: ShaderAnimeFireOptions = {} +): ShaderAnimeFireInstance | null { + let options: ShaderAnimeFireOptions = { interactive: true, theme: "auto", ...initial, @@ -392,7 +392,7 @@ export function createShaderFire( if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { if (isDev()) { console.warn( - "ShaderFire: program failed to link\n", + "ShaderAnimeFire: program failed to link\n", gl.getProgramInfoLog(program) ) } diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index c708d21..73d9b23 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -92,7 +92,7 @@ flowchart TD pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} atmo -->|Quiet wash| sg[shader-gradient] - atmo -->|Fire from the bottom| sf[shader-fire] + atmo -->|Fire from the bottom| sf[shader-anime-fire] atmo -->|Sky or rain, optional glass| ss[shader-sky] atmo -->|ASCII mouse trails| af[ascii-fluid] atmo -->|Hyperspace streaks, scroll warp| rl[radiant-lines] @@ -109,8 +109,8 @@ Disambiguation that agents get wrong: | --- | --- | --- | | Color picker, swatch, hex, hue, alpha, eyedropper | `gooey-color-picker` | Any shader. Shaders are not controls. | | Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | -| Fire, embers, heat under a hero | `shader-fire` | `dithered-404` unless the page is a 404 | -| 404 that burns under the cursor | `dithered-404` | `shader-fire` | +| Fire, embers, heat under a hero | `shader-anime-fire` | `dithered-404` unless the page is a 404 | +| 404 that burns under the cursor | `dithered-404` | `shader-anime-fire` | | Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | 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` | @@ -132,7 +132,7 @@ Backgrounds and shaders fill the parent. They do not create a page.
``` -Same shell for `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`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `RadiantLines` is transparent and warps with scroll. Inside an overflow div, pass the scroller (`containerRef` in React, `container` in Svelte) and make the canvas `sticky top-0 h-svh`. Omit the scroller to use the window. @@ -153,7 +153,7 @@ That matches shadcn / `next-themes` with `attribute="class"`. There is no shared Exceptions: -- Passing `colors` to `ShaderGradient`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. +- Passing `colors` to `ShaderGradient`, `ShaderAnimeFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. - `PhosphorScore` canvas is transparent in both themes; the parent paints the background. - `Folio` tilt peaks at an internal 16°. It is not a prop. The lean follows wheel speed, so trackpads and mice lean alike. Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. @@ -171,7 +171,7 @@ Categories match `content/docs/components/meta.json`. | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | -| `shader-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | +| `shader-anime-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | | `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | | `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | React + Svelte | | `live-orb` | Characters | Lit sphere; eyes follow the pointer | React + Svelte | diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index e975d29..73382bc 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -147,12 +147,12 @@ type ShaderGradientProps = { `LIGHT_FALLBACK` and `DARK_FALLBACK` are CSS background stacks used when WebGL is unavailable. Palette line in the engine: `options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)`. -## shader-fire +## shader-anime-fire ```ts -type ShaderFireProps = { +type ShaderAnimeFireProps = { colors?: string[] // ember, flame, core - speed?: number // 1 + speed?: number // 0.6 intensity?: number // 1 height?: number // 0.45 embers?: number // 0.6 diff --git a/skills/23rd/components.md b/skills/23rd/components.md index f6e2dbe..6f085ad 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -44,7 +44,7 @@ Background. Two vertical staves. Notes fall, bloom at the playhead, then flare. Best fit: a music, broadcast, or terminal hero that should feel like a CRT score. -Not this: fire (`shader-fire`), sky (`shader-sky`), a footer. +Not this: fire (`shader-anime-fire`), sky (`shader-sky`), a footer. Deps: none. Key props: `color`, `glow` (50, range 0–100), `speed` (1.35 beats/s), `density` (1), `sway` (true), `seed` (23), `theme`. @@ -143,7 +143,7 @@ export function Hero() { Pitfalls: custom `colors` stick; they do not cross-fade into the dark palette. Stock light `#7CB4E0 #B4D8C4 #EFE4BC #D2D7EC`. Stock dark `#3A6FA0 #2F6B52 #8A6B32 #4A4D7A`. If WebGL fails, a CSS `LIGHT_FALLBACK` / `DARK_FALLBACK` radial stack is used. `interactive` eases the wash toward the pointer. -## shader-fire +## shader-anime-fire Shader. Cel-shaded flames lick up from the bottom edge, sparks drift above. Ember / flame / core. @@ -151,16 +151,16 @@ Best fit: fire along the floor of a hero. `dither` prints the bands as Bayer pix Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). -Deps: none. Key props: `colors`, `speed` (1), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. +Deps: none. Key props: `colors`, `speed` (0.6), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. ```tsx "use client" -import { ShaderFire } from "@/components/ui/shader-fire" +import { ShaderAnimeFire } from "@/components/ui/shader-anime-fire" export function Hero() { return (
- +
Your content
) @@ -281,7 +281,7 @@ Page. A Bayer-dithered “404” burned by a fireball cursor into embers and smo Best fit: the not-found route, full viewport. -Not this: decorative fire behind a normal hero (`shader-fire`). Set `dither={false}` only when you want the soft fire on the same 404 glyph. +Not this: decorative fire behind a normal hero (`shader-anime-fire`). Set `dither={false}` only when you want the soft fire on the same 404 glyph. Deps: none. Key props: `color`, `pixelSize` (4), `brush` (28), `interactive` (true), `dither` (true), `theme`. diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index c4a4e95..42d5bb9 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -32,8 +32,8 @@ Swap the component, keep the shell: | Brief | Component | Extra props | | --- | --- | --- | -| Fire along the floor | `ShaderFire` | `height={0.45}` | -| Pixel fire | `ShaderFire` | `dither pixelSize={3}` | +| Fire along the floor | `ShaderAnimeFire` | `height={0.45}` | +| Pixel fire | `ShaderAnimeFire` | `dither pixelSize={3}` | | Blue sky | `ShaderSky` | leave `colors` unset | | Rain / dusk sky | `ShaderSky` | `theme="dark"` or `html.dark` | | Window glass | `ShaderSky` | `glass glassSize={7}` | @@ -76,7 +76,7 @@ Svelte: `bind:this={scroller}` and `container={scroller}`. ## 404 -Use `dithered-404` on the not-found route. Do not also mount `shader-fire`. +Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. ```tsx "use client" @@ -97,7 +97,7 @@ export function NotFound() { } ``` -`dither={false}` keeps the 404 glyph and switches to soft fire. It does not turn the page into `shader-fire`. +`dither={false}` keeps the 404 glyph and switches to soft fire. It does not turn the page into `shader-anime-fire`. ## Rubber overscroll @@ -224,7 +224,7 @@ export function Brand() { - `ShaderGradient` as a color control, or `GooeyColorPicker` as a page background. - `StretchyFooter` and `Folio` both wrapping the same scroll. Pick one owner of the scroll. -- Two of `ShaderGradient`, `ShaderFire`, `ShaderSky`, `AsciiFluid` in the same viewport. +- Two of `ShaderGradient`, `ShaderAnimeFire`, `ShaderSky`, `AsciiFluid` in the same viewport. - Content as a sibling with no `z-10`, so the canvas eats clicks. Set `pointer-events-none` on the canvas wrapper when the field is decorative (`LogoBurst` click-to-replay is the exception). - Assuming `theme="auto"` recolors a shader after you passed `colors`. It will not. - Adding `variant` to anything but `LiveOrb`. From 31b55f38b2e946a22cb09ce1fd3178cd64e089a2 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 13:21:47 +0530 Subject: [PATCH 29/49] Finish renaming Shader Fire to Shader Anime Fire. The previous two commits only carried the file moves and part of the content; this brings the wrappers, demo, registry manifests, landing feed, docs links and the permanent redirect from the old docs URL along, and drops Dithered 404's comparison to this fire. --- README.md | 2 +- components/component-controls.tsx | 2 +- components/monitor-feeds.tsx | 6 ++-- content/docs/components/dithered-404.mdx | 2 +- content/docs/components/meta.json | 2 +- content/docs/getting-started.mdx | 6 ++-- next.config.mjs | 5 +++ .../components/shader-anime-fire/image.png | Bin 46265 -> 48510 bytes public/r/dithered-404-svelte.json | 2 +- public/r/dithered-404.json | 2 +- public/r/registry.json | 16 +++++----- registry.json | 2 +- registry/dithered-404/dithered-404-vanilla.ts | 2 +- registry/dithered-404/dithered-404.tsx | 2 +- registry/shader-anime-fire/registry.json | 16 +++++----- .../shader-anime-fire-demo.tsx | 10 +++--- .../shader-anime-fire.svelte | 16 +++++----- .../shader-anime-fire/shader-anime-fire.tsx | 30 +++++++++--------- 18 files changed, 64 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 138a9ac..1354e57 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ npx skills add radiumcoders/23rd.dev --skill 23rd -g | [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 | | [Radiant Lines](https://23rd.dev/docs/components/radiant-lines) | `@23rd/radiant-lines` | Hyperspace starfield background; warp speed driven by scroll | -| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Cel-shaded flames and sparks rising from the bottom of a landing hero | +| [Shader Anime Fire](https://23rd.dev/docs/components/shader-anime-fire) | `@23rd/shader-anime-fire` | Cel-shaded flames and sparks rising from the bottom of a landing hero | | [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections | | [Shader Sky](https://23rd.dev/docs/components/shader-sky) | `@23rd/shader-sky` | Clear blue or rain behind a hero — drifting clouds, optional window-glass film | | [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back | diff --git a/components/component-controls.tsx b/components/component-controls.tsx index 4a8b5a8..ee108a0 100644 --- a/components/component-controls.tsx +++ b/components/component-controls.tsx @@ -98,7 +98,7 @@ export type ComponentControlsProps = { hasChanges?: boolean onReset?: () => void className?: string - /** Component tag for the copy snippet, e.g. `"ShaderFire"` */ + /** Component tag for the copy snippet, e.g. `"ShaderAnimeFire"` */ component?: string /** Props included in the copied JSX. `undefined` values are omitted. */ snippetProps?: Record diff --git a/components/monitor-feeds.tsx b/components/monitor-feeds.tsx index 08f2cd0..87f7335 100644 --- a/components/monitor-feeds.tsx +++ b/components/monitor-feeds.tsx @@ -42,8 +42,8 @@ const PhosphorScore = feed(() => const RadiantLines = feed(() => import("@/registry/radiant-lines/radiant-lines").then((m) => m.RadiantLines) ) -const ShaderFire = feed(() => - import("@/registry/shader-fire/shader-fire").then((m) => m.ShaderFire) +const ShaderAnimeFire = feed(() => + import("@/registry/shader-anime-fire/shader-anime-fire").then((m) => m.ShaderAnimeFire) ) const ShaderGradient = feed(() => import("@/registry/shader-gradient/shader-gradient").then( @@ -71,7 +71,7 @@ const FEEDS: Record ReactNode> = { "logo-burst": () => , "phosphor-score": () => , "radiant-lines": () => , - "shader-fire": () => , + "shader-anime-fire": () => , "shader-gradient": () => , "shader-sky": () => , } diff --git a/content/docs/components/dithered-404.mdx b/content/docs/components/dithered-404.mdx index 93149de..c5a3509 100644 --- a/content/docs/components/dithered-404.mdx +++ b/content/docs/components/dithered-404.mdx @@ -5,7 +5,7 @@ description: A Bayer-pixel 404 section — the pointer becomes a dithered fireba import { Dithered404Demo } from "@/registry/dithered-404/dithered-404-demo" -Bold **404** printed as ordered Bayer pixels — or turn **Dither** off for a soft, realistic fire like Shader Fire. Move over the type: the cursor is a coal. Linger and the glyph scorches dark, then burns into embers and smoke. Leftover type stays put until you burn it. After most of the number is gone, it assembles again and you can burn it once more. Press `d` to switch theme. +Bold **404** printed as ordered Bayer pixels — or turn **Dither** off for a soft, realistic fire. Move over the type: the cursor is a coal. Linger and the glyph scorches dark, then burns into embers and smoke. Leftover type stays put until you burn it. After most of the number is gone, it assembles again and you can burn it once more. Press `d` to switch theme. ## Preview diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index e87cea2..aea67fe 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -8,7 +8,7 @@ "ascii-fluid", "---Shaders---", "shader-gradient", - "shader-fire", + "shader-anime-fire", "shader-sky", "---Footers---", "stretchy-footer", diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index 56b9231..2d92560 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -13,7 +13,7 @@ Or pull straight from the public GitHub source registry: -Swap `shader-gradient` for any item name — `shader-sky`, `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve. +Swap `shader-gradient` for any item name — `shader-sky`, `shader-anime-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve. ## Use a background or shader @@ -50,14 +50,14 @@ export function Hero() { -Shaders such as [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire), and [Shader Sky](/docs/components/shader-sky) are atmosphere, not the page. +Shaders such as [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), and [Shader Sky](/docs/components/shader-sky) are atmosphere, not the page. ## 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 Fire](/docs/components/shader-fire), [Shader Sky](/docs/components/shader-sky) | +| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky) | | 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) | diff --git a/next.config.mjs b/next.config.mjs index 7ebb9ad..5c76c04 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,11 @@ const config = { destination: "/docs/getting-started", permanent: false, }, + { + source: "/docs/components/shader-fire", + destination: "/docs/components/shader-anime-fire", + permanent: true, + }, { source: "/pricing", destination: "/sponsors", diff --git a/public/og/docs/components/shader-anime-fire/image.png b/public/og/docs/components/shader-anime-fire/image.png index 75d1d565314262ebecc5807b6076de78029b0d48..c1ec26451209bcca812880d4ad1cca2c5c064a7b 100644 GIT binary patch literal 48510 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 a-aBj=l%ADW^~XN;Kk!YX8|BxW9{n${%DZC# literal 46265 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 iH2z(Y0;h5A34Jh2nEgcmEefAxw+_ diff --git a/public/r/dithered-404-svelte.json b/public/r/dithered-404-svelte.json index d8133fa..4e22b37 100644 --- a/public/r/dithered-404-svelte.json +++ b/public/r/dithered-404-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/dithered-404/dithered-404.svelte", - "content": "\n\n\n\n\n 404\n \n\n", + "content": "\n\n\n\n\n 404\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/dithered-404.svelte" } diff --git a/public/r/dithered-404.json b/public/r/dithered-404.json index 0f4f047..bf8388f 100644 --- a/public/r/dithered-404.json +++ b/public/r/dithered-404.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/dithered-404/dithered-404.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Dithered404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Dithered404Options = {\n /** Glyph ink (hex). Default follows theme. */\n color?: string\n /** Dither cell size in CSS px. Default `4` */\n pixelSize?: number\n /** Fireball radius in CSS px. Default `28` */\n brush?: number\n /** Follow pointer / replace cursor. Default `true` */\n interactive?: boolean\n /** Ordered Bayer print. Default `true`. Off is a soft realistic fire. */\n dither?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Dithered404Theme\n /** Fires when the fireball cursor should hide the system pointer. */\n onHideCursor?: (hide: boolean) => void\n}\n\nexport type Dithered404Instance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n\nconst FIRE_EMBER = \"#A33A18\"\nconst FIRE_FLAME = \"#D4682A\"\nconst FIRE_HIGHLIGHT = \"#E8B45A\"\n\nconst SOLID = 0\nconst EMBER = 1\nconst SMOKE = 2\nconst REFORMING = 3\n\nconst PHASE_READY = 0\nconst PHASE_REFORMING = 1\n\nconst RESET_DELAY_MS = 900\nconst REFORM_SNAP_MS = 1800\nconst COLLAPSE_RATIO = 0.12\nconst MAX_EMBERS = 900\nconst MAX_EMBERS_FINE = 260\nconst MAX_SMOKE = 420\nconst MAX_SMOKE_FINE = 160\nconst CHAR_HEAT_GAIN = 0.2\nconst CHAR_COOL = 0.01\n\nconst BAYER8 = [\n [0, 32, 8, 40, 2, 34, 10, 42],\n [48, 16, 56, 24, 50, 18, 58, 26],\n [12, 44, 4, 36, 14, 46, 6, 38],\n [60, 28, 52, 20, 62, 30, 54, 22],\n [3, 35, 11, 43, 1, 33, 9, 41],\n [51, 19, 59, 27, 49, 17, 57, 25],\n [15, 47, 7, 39, 13, 45, 5, 37],\n [63, 31, 55, 23, 61, 29, 53, 21],\n] as const\n\ntype Particle = {\n x: number\n y: number\n homeX: number\n homeY: number\n vx: number\n vy: number\n size: number\n state: 0 | 1 | 2 | 3\n life: number\n rot: number\n vr: number\n gx: number\n gy: number\n fill: string\n alpha: number\n sleep: number\n heat: number\n}\n\ntype GlyphCell = {\n homeX: number\n homeY: number\n gx: number\n gy: number\n alpha: number\n}\n\nfunction bayerAt(gx: number, gy: number) {\n const x = ((gx % 8) + 8) % 8\n const y = ((gy % 8) + 8) % 8\n return BAYER8[y]![x]! / 64\n}\n\nfunction hash2(x: number, y: number) {\n const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123\n return s - Math.floor(s)\n}\n\nfunction valueNoise(x: number, y: number) {\n const ix = Math.floor(x)\n const iy = Math.floor(y)\n const fx = x - ix\n const fy = y - iy\n const a = hash2(ix, iy)\n const b = hash2(ix + 1, iy)\n const c = hash2(ix, iy + 1)\n const d = hash2(ix + 1, iy + 1)\n const ux = fx * fx * (3 - 2 * fx)\n const uy = fy * fy * (3 - 2 * fy)\n return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy\n}\n\nfunction fbm(x: number, y: number) {\n let v = 0\n let a = 0.5\n let f = 1\n for (let i = 0; i < 3; i++) {\n v += valueNoise(x * f, y * f) * a\n a *= 0.5\n f *= 2.05\n }\n return v\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: Dithered404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction cellKey(gx: number, gy: number) {\n return gx * 4096 + gy\n}\n\nfunction fireTint(field: number) {\n if (field > 0.72) return FIRE_HIGHLIGHT\n if (field > 0.4) return FIRE_FLAME\n return FIRE_EMBER\n}\n\nfunction hexRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [163, 58, 24]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n}\n\nfunction rgba(hex: string, a: number) {\n const [r, g, b] = hexRgb(hex)\n return `rgba(${r},${g},${b},${a})`\n}\n\nfunction scorchedInk(ink: string, heat: number): string {\n if (heat <= 0.02) return ink\n const [r, g, b] = hexRgb(ink)\n const h = heat > 1 ? 1 : heat\n const lum = (r + g + b) / 3\n let nr: number\n let ng: number\n let nb: number\n if (lum >= 130) {\n const k = 1 - h * 0.9\n nr = r * k\n ng = g * k * (1 - h * 0.12)\n nb = b * k * (1 - h * 0.22)\n if (h > 0.5) {\n const g2 = (h - 0.5) / 0.5\n nr = nr * (1 - g2 * 0.35) + 163 * g2 * 0.55\n ng = ng * (1 - g2 * 0.35) + 58 * g2 * 0.4\n nb = nb * (1 - g2 * 0.35) + 24 * g2 * 0.25\n }\n } else {\n nr = r * (1 - h * 0.22) + 52 * h\n ng = g * (1 - h * 0.38) + 18 * h\n nb = b * (1 - h * 0.52) + 8 * h\n if (h > 0.55) {\n const g2 = (h - 0.55) / 0.45\n nr = nr * (1 - g2 * 0.4) + 163 * g2 * 0.7\n ng = ng * (1 - g2 * 0.4) + 58 * g2 * 0.5\n nb = nb * (1 - g2 * 0.4) + 24 * g2 * 0.3\n }\n }\n return `rgb(${nr | 0},${ng | 0},${nb | 0})`\n}\n\nfunction rasterizeGlyph(\n cssW: number,\n cssH: number,\n pixelSize: number,\n dpr: number,\n dither: boolean\n): GlyphCell[] {\n const w = Math.max(1, Math.floor(cssW * dpr))\n const h = Math.max(1, Math.floor(cssH * dpr))\n const cell = Math.max(1, Math.round(pixelSize * dpr))\n const off = document.createElement(\"canvas\")\n off.width = w\n off.height = h\n const ctx = off.getContext(\"2d\", { willReadFrequently: true })\n if (!ctx) return []\n\n const family = getComputedStyle(document.body).fontFamily || \"ui-sans-serif\"\n const fontSize = Math.min(cssW * 0.54, cssH * 0.5) * dpr\n ctx.clearRect(0, 0, w, h)\n ctx.fillStyle = \"#fff\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = `800 ${fontSize}px ${family}`\n if (\"letterSpacing\" in ctx) {\n ;(\n ctx as CanvasRenderingContext2D & { letterSpacing: string }\n ).letterSpacing = `${-fontSize * 0.045}px`\n }\n ctx.fillText(\"404\", w / 2, h / 2)\n\n const data = ctx.getImageData(0, 0, w, h).data\n const cells: GlyphCell[] = []\n\n for (let y = 0; y < h; y += cell) {\n for (let x = 0; x < w; x += cell) {\n let cover = 0\n let samples = 0\n const step = Math.max(1, Math.floor(cell / 3))\n for (let sy = 0; sy < cell; sy += step) {\n for (let sx = 0; sx < cell; sx += step) {\n const px = x + sx\n const py = y + sy\n if (px >= w || py >= h) continue\n cover += data[(py * w + px) * 4 + 3]!\n samples++\n }\n }\n const alpha = samples > 0 ? cover / (samples * 255) : 0\n if (alpha < (dither ? 0.18 : 0.08)) continue\n const gx = Math.floor(x / cell)\n const gy = Math.floor(y / cell)\n if (dither && alpha < bayerAt(gx, gy) * 0.92 + 0.08) continue\n cells.push({\n homeX: x / dpr,\n homeY: y / dpr,\n gx,\n gy,\n alpha: dither ? 1 : Math.min(1, alpha * 1.15),\n })\n }\n }\n\n return cells\n}\n\nfunction spawnFromCells(\n cells: GlyphCell[],\n pixelSize: number,\n ink: string,\n cssH: number,\n assemble: boolean\n): Particle[] {\n return cells.map((cell) => {\n const fromBelow = assemble\n return {\n x: fromBelow ? cell.homeX + (Math.random() - 0.5) * 28 : cell.homeX,\n y: fromBelow ? cssH + 12 + Math.random() * 90 : cell.homeY,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: fromBelow ? -2.2 - Math.random() * 1.4 : 0,\n size: pixelSize,\n state: fromBelow ? REFORMING : SOLID,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n }\n })\n}\n\nfunction smokeFill(dark: boolean) {\n if (dark) {\n return Math.random() > 0.45 ? \"#A1A1AA\" : \"#71717A\"\n }\n return Math.random() > 0.45 ? \"#52525B\" : \"#3F3F46\"\n}\n\nfunction burnParticle(\n particles: Particle[],\n p: Particle,\n emberCount: { n: number },\n smokeCount: { n: number },\n pixelSize: number,\n maxEmbers: number,\n maxSmoke: number,\n dark: boolean\n) {\n p.state = EMBER\n p.life = 0.28 + Math.random() * (pixelSize <= 2 ? 0.28 : 0.45)\n p.size = Math.max(1, p.size * 0.55)\n p.vx = (Math.random() - 0.5) * 2.8\n p.vy = -1.2 - Math.random() * 2.4\n p.fill = fireTint(0.35 + Math.random() * 0.65)\n p.alpha = 1\n p.heat = 0\n emberCount.n++\n\n const extra =\n pixelSize <= 2\n ? Math.random() < 0.22\n ? 1\n : 0\n : 1 + Math.floor(Math.random() * 3)\n for (let i = 0; i < extra && emberCount.n < maxEmbers; i++) {\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size,\n y: p.y + (Math.random() - 0.5) * p.size,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 3.4,\n vy: -1.6 - Math.random() * 2.8,\n size: Math.max(1, p.size * (0.35 + Math.random() * 0.4)),\n state: EMBER,\n life: 0.22 + Math.random() * 0.4,\n rot: 0,\n vr: 0,\n gx: p.gx,\n gy: p.gy,\n fill: fireTint(0.25 + Math.random() * 0.75),\n alpha: 1,\n sleep: 0,\n heat: 0,\n })\n emberCount.n++\n }\n\n const puffs =\n pixelSize <= 2\n ? Math.random() < 0.55\n ? 1\n : 0\n : 1 + (Math.random() < 0.55 ? 1 : 0)\n for (let i = 0; i < puffs && smokeCount.n < maxSmoke; i++) {\n const puff = pixelSize * (1.6 + Math.random() * 1.8)\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size * 0.8,\n y: p.y + (Math.random() - 0.5) * p.size * 0.8,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 0.55,\n vy: -0.45 - Math.random() * 0.85,\n size: puff,\n state: SMOKE,\n life: 0.75 + Math.random() * 0.95,\n rot: 0,\n vr: (Math.random() - 0.5) * 0.04,\n gx: p.gx,\n gy: p.gy,\n fill: smokeFill(dark),\n alpha: 0.42,\n sleep: 0,\n heat: 0,\n })\n smokeCount.n++\n }\n}\n\nfunction fireFieldAt(\n x: number,\n y: number,\n mx: number,\n my: number,\n r: number,\n t: number\n) {\n const dx = (x - mx) / r\n const dy = (y - my) / r\n const px = dx\n const py = dy > 0 ? dy * 1.4 : dy * 0.72\n const dist = Math.hypot(px, py)\n if (dist > 1.12) return 0\n const n = fbm(x * 0.055 + t * 1.7, y * 0.06 - t * 2.6)\n return Math.max(0, (1 - dist) * (0.38 + 0.62 * n))\n}\n\nfunction drawFireball(\n ctx: CanvasRenderingContext2D,\n mx: number,\n my: number,\n t: number,\n pixelSize: number,\n brush: number,\n dither: boolean\n) {\n const r = brush\n const minX = mx - r\n const maxX = mx + r\n const minY = my - r * 1.35\n const maxY = my + r * 0.85\n\n if (!dither) {\n ctx.save()\n ctx.globalCompositeOperation = \"lighter\"\n const glow = ctx.createRadialGradient(\n mx,\n my - r * 0.22,\n r * 0.04,\n mx,\n my - r * 0.08,\n r * 1.05\n )\n glow.addColorStop(0, rgba(FIRE_HIGHLIGHT, 0.95))\n glow.addColorStop(0.28, rgba(FIRE_FLAME, 0.72))\n glow.addColorStop(0.62, rgba(FIRE_EMBER, 0.32))\n glow.addColorStop(1, rgba(FIRE_EMBER, 0))\n ctx.fillStyle = glow\n ctx.beginPath()\n ctx.ellipse(mx, my - r * 0.18, r * 0.78, r * 1.12, 0, 0, Math.PI * 2)\n ctx.fill()\n\n const cell = 2\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const field = fireFieldAt(x + 1, y + 1, mx, my, r, t)\n if (field < 0.08) continue\n ctx.globalAlpha = Math.min(0.9, field * 1.05)\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(x, y, cell + 0.5, cell + 0.5)\n }\n }\n ctx.restore()\n return\n }\n\n const cell = Math.max(3, pixelSize)\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const cx = Math.floor(x / cell) * cell\n const cy = Math.floor(y / cell) * cell\n const field = fireFieldAt(cx + cell * 0.5, cy + cell * 0.5, mx, my, r, t)\n if (field < 0.1) continue\n if (\n field <\n bayerAt(Math.floor(cx / cell), Math.floor(cy / cell)) * 0.9 + 0.06\n ) {\n continue\n }\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(cx, cy, cell, cell)\n }\n }\n}\n\n/**\n * Bayer-pixel 404 section — a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire, same idea as Shader Fire.\n */\nexport function createDithered404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Dithered404Options = {}\n): Dithered404Instance | null {\n let options: Required<\n Pick<\n Dithered404Options,\n \"pixelSize\" | \"brush\" | \"interactive\" | \"dither\" | \"theme\"\n >\n > &\n Dithered404Options = {\n pixelSize: 4,\n brush: 28,\n interactive: true,\n dither: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true, desynchronized: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let hover = false\n const mouse = { x: 0, y: 0 }\n const targetMouse = { x: 0, y: 0 }\n\n let raf = 0\n let running = true\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cells: GlyphCell[] = []\n let particles: Particle[] = []\n let lastPixel = -1\n let lastDither: boolean | null = null\n let lastW = 0\n let lastH = 0\n let phase = PHASE_READY\n let resetAt = 0\n let reformAt = 0\n let lastNow = performance.now()\n const start = lastNow\n\n const inkOf = () => options.color ?? (dark ? DARK_COLOR : LIGHT_COLOR)\n\n const rebuild = (assemble: boolean) => {\n const px = Math.max(2, Math.round(options.pixelSize))\n const useDither = options.dither\n cells = rasterizeGlyph(cssW, cssH, px, dpr, useDither)\n particles = spawnFromCells(cells, px, inkOf(), cssH, assemble)\n phase = assemble ? PHASE_REFORMING : PHASE_READY\n resetAt = 0\n reformAt = assemble ? performance.now() : 0\n lastPixel = px\n lastDither = useDither\n }\n\n const startReform = (now: number, px: number, ink: string) => {\n const have = new Set()\n for (const p of particles) {\n if (p.state === EMBER || p.state === SMOKE) continue\n p.state = REFORMING\n p.size = px\n p.fill = ink\n p.vx = 0\n p.vy = 0\n p.vr = 0\n p.sleep = 0\n p.life = 1\n p.heat = 0\n have.add(cellKey(p.gx, p.gy))\n }\n for (const cell of cells) {\n if (have.has(cellKey(cell.gx, cell.gy))) continue\n particles.push({\n x: cell.homeX + (Math.random() - 0.5) * 22,\n y: cssH - px - Math.random() * 52,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: 0,\n size: px,\n state: REFORMING,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n })\n }\n phase = PHASE_REFORMING\n resetAt = 0\n reformAt = now\n }\n\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return\n cssW = w\n cssH = h\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.imageSmoothingEnabled = false\n if (w !== lastW || h !== lastH) {\n lastW = w\n lastH = h\n rebuild(false)\n }\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n ro.observe(wrap)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive || reduce) return\n const rect = wrap.getBoundingClientRect()\n targetMouse.x = e.clientX - rect.left\n targetMouse.y = e.clientY - rect.top\n if (!hover) {\n hover = true\n options.onHideCursor?.(true)\n }\n }\n const onLeave = () => {\n hover = false\n options.onHideCursor?.(false)\n }\n\n wrap.addEventListener(\"pointermove\", onMove, { passive: true })\n wrap.addEventListener(\"pointerenter\", onMove, { passive: true })\n wrap.addEventListener(\"pointerleave\", onLeave)\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(32, now - lastNow) / 16.67\n lastNow = now\n const t = (now - start) / 1000\n const { pixelSize, brush, interactive, dither } = options\n const px = Math.max(2, Math.round(pixelSize))\n const ink = inkOf()\n\n if ((px !== lastPixel || dither !== lastDither) && cssW > 0) {\n rebuild(false)\n }\n\n mouse.x += (targetMouse.x - mouse.x) * 0.28\n mouse.y += (targetMouse.y - mouse.y) * 0.28\n\n if (!reduce) {\n if (resetAt && now >= resetAt) {\n startReform(now, px, ink)\n }\n\n let solidCount = 0\n let emberCount = 0\n let smokeCount = 0\n let reformingCount = 0\n const emberBudget = { n: 0 }\n const smokeBudget = { n: 0 }\n const maxEmbers = px <= 2 ? MAX_EMBERS_FINE : MAX_EMBERS\n const maxSmoke = px <= 2 ? MAX_SMOKE_FINE : MAX_SMOKE\n\n const canBurn = interactive && hover && phase === PHASE_READY\n\n const scorchR = brush * 0.92\n const scorchR2 = scorchR * scorchR\n const burnR = brush * 0.62\n const burnR2 = burnR * burnR\n\n for (let i = particles.length - 1; i >= 0; i--) {\n const p = particles[i]!\n if (p.state === SOLID) {\n if (canBurn) {\n const dx = p.x + p.size * 0.5 - mouse.x\n const dy = p.y + p.size * 0.5 - mouse.y\n const d2 = dx * dx + dy * dy\n if (d2 < scorchR2) {\n const heat = 1 - Math.sqrt(d2) / scorchR\n p.heat = Math.min(1, p.heat + heat * CHAR_HEAT_GAIN * dt)\n if (\n p.heat > 0.58 &&\n d2 < burnR2 &&\n Math.random() < heat * 0.28 * dt\n ) {\n burnParticle(\n particles,\n p,\n emberBudget,\n smokeBudget,\n px,\n maxEmbers,\n maxSmoke,\n dark\n )\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n if (p.state === SOLID) {\n p.fill = p.heat > 0 ? scorchedInk(ink, p.heat) : ink\n }\n }\n\n if (p.state === EMBER) {\n p.vy -= 0.09 * dt\n p.vx *= 0.94\n p.vy *= 0.97\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.life -= (px <= 2 ? 0.04 : 0.028) * dt\n p.size = Math.max(0.6, p.size * (1 - 0.02 * dt))\n if (p.life <= 0) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n emberCount++\n continue\n }\n\n if (p.state === SMOKE) {\n p.vy -= 0.018 * dt\n p.vx += Math.sin((now + p.x * 12) * 0.002) * 0.012 * dt\n p.vx *= 0.985\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.size += 0.12 * dt\n p.life -= (px <= 2 ? 0.018 : 0.012) * dt\n p.alpha = Math.max(0, p.life * 0.5)\n if (p.life <= 0 || p.y + p.size < -40) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n smokeCount++\n continue\n }\n\n if (p.state === REFORMING) {\n p.x += (p.homeX - p.x) * 0.16 * dt\n p.y += (p.homeY - p.y) * 0.16 * dt\n p.rot += (0 - p.rot) * 0.22 * dt\n p.vx = 0\n p.vy = 0\n p.fill = ink\n p.heat = 0\n if (Math.hypot(p.homeX - p.x, p.homeY - p.y) < 0.7) {\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n solidCount++\n } else {\n reformingCount++\n }\n continue\n }\n\n if (p.state === SOLID) {\n solidCount++\n }\n }\n\n const initialCount = cells.length || 1\n if (\n phase === PHASE_READY &&\n cells.length > 0 &&\n solidCount / initialCount < COLLAPSE_RATIO &&\n resetAt === 0\n ) {\n resetAt = now + RESET_DELAY_MS\n }\n\n if (phase === PHASE_REFORMING) {\n if (reformingCount === 0) {\n phase = PHASE_READY\n } else if (reformAt && now - reformAt > REFORM_SNAP_MS) {\n for (const p of particles) {\n if (p.state !== REFORMING) continue\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n p.fill = ink\n }\n phase = PHASE_READY\n }\n }\n\n if (emberCount > maxEmbers) {\n let extra = emberCount - maxEmbers\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === EMBER) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n\n if (smokeCount > maxSmoke) {\n let extra = smokeCount - maxSmoke\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === SMOKE) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n }\n\n ctx.clearRect(0, 0, cssW, cssH)\n ctx.imageSmoothingEnabled = !dither\n\n for (const p of particles) {\n if (p.state === EMBER) {\n ctx.globalAlpha = Math.max(0, Math.min(1, p.life * 1.4))\n ctx.fillStyle = p.fill\n if (!dither) {\n const glow = Math.max(1.2, p.size * 0.55)\n ctx.beginPath()\n ctx.arc(p.x + p.size * 0.5, p.y + p.size * 0.5, glow, 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n if (p.state === SMOKE) {\n ctx.globalAlpha = Math.max(0, Math.min(0.45, p.alpha))\n ctx.fillStyle = p.fill\n const half = p.size * 0.5\n if (!dither) {\n ctx.beginPath()\n ctx.arc(p.x + half, p.y + half, Math.max(1.2, half), 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n ctx.fillStyle = p.fill\n ctx.globalAlpha = dither ? 1 : p.alpha\n const pad = dither ? 0 : 0.35\n if (p.state === REFORMING && Math.abs(p.rot) > 0.04) {\n const half = p.size * 0.5\n ctx.save()\n ctx.translate(p.x + half, p.y + half)\n ctx.rotate(p.rot)\n ctx.fillRect(\n -half - pad,\n -half - pad,\n p.size + pad * 2,\n p.size + pad * 2\n )\n ctx.restore()\n } else {\n ctx.fillRect(p.x - pad, p.y - pad, p.size + pad * 2, p.size + pad * 2)\n }\n }\n\n ctx.globalAlpha = 1\n if (interactive && hover && !reduce) {\n drawFireball(ctx, mouse.x, mouse.y, t, px, brush, dither)\n }\n\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"pointermove\", onMove)\n wrap.removeEventListener(\"pointerenter\", onMove)\n wrap.removeEventListener(\"pointerleave\", onLeave)\n options.onHideCursor?.(false)\n },\n }\n}\n\nexport type Dithered404Props = Dithered404Options & {\n className?: string\n}\n\n/**\n * Bayer-pixel 404 section — a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire, same idea as Shader Fire.\n */\nexport function Dithered404({\n className,\n color,\n pixelSize = 4,\n brush = 28,\n interactive = true,\n dither = true,\n theme = \"auto\",\n}: Dithered404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hideCursor, setHideCursor] = useState(false)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createDithered404(wrap, canvas, {\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n }, [color, pixelSize, brush, interactive, dither, theme])\n\n return (\n \n 404\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Dithered404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Dithered404Options = {\n /** Glyph ink (hex). Default follows theme. */\n color?: string\n /** Dither cell size in CSS px. Default `4` */\n pixelSize?: number\n /** Fireball radius in CSS px. Default `28` */\n brush?: number\n /** Follow pointer / replace cursor. Default `true` */\n interactive?: boolean\n /** Ordered Bayer print. Default `true`. Off is a soft realistic fire. */\n dither?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Dithered404Theme\n /** Fires when the fireball cursor should hide the system pointer. */\n onHideCursor?: (hide: boolean) => void\n}\n\nexport type Dithered404Instance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n\nconst FIRE_EMBER = \"#A33A18\"\nconst FIRE_FLAME = \"#D4682A\"\nconst FIRE_HIGHLIGHT = \"#E8B45A\"\n\nconst SOLID = 0\nconst EMBER = 1\nconst SMOKE = 2\nconst REFORMING = 3\n\nconst PHASE_READY = 0\nconst PHASE_REFORMING = 1\n\nconst RESET_DELAY_MS = 900\nconst REFORM_SNAP_MS = 1800\nconst COLLAPSE_RATIO = 0.12\nconst MAX_EMBERS = 900\nconst MAX_EMBERS_FINE = 260\nconst MAX_SMOKE = 420\nconst MAX_SMOKE_FINE = 160\nconst CHAR_HEAT_GAIN = 0.2\nconst CHAR_COOL = 0.01\n\nconst BAYER8 = [\n [0, 32, 8, 40, 2, 34, 10, 42],\n [48, 16, 56, 24, 50, 18, 58, 26],\n [12, 44, 4, 36, 14, 46, 6, 38],\n [60, 28, 52, 20, 62, 30, 54, 22],\n [3, 35, 11, 43, 1, 33, 9, 41],\n [51, 19, 59, 27, 49, 17, 57, 25],\n [15, 47, 7, 39, 13, 45, 5, 37],\n [63, 31, 55, 23, 61, 29, 53, 21],\n] as const\n\ntype Particle = {\n x: number\n y: number\n homeX: number\n homeY: number\n vx: number\n vy: number\n size: number\n state: 0 | 1 | 2 | 3\n life: number\n rot: number\n vr: number\n gx: number\n gy: number\n fill: string\n alpha: number\n sleep: number\n heat: number\n}\n\ntype GlyphCell = {\n homeX: number\n homeY: number\n gx: number\n gy: number\n alpha: number\n}\n\nfunction bayerAt(gx: number, gy: number) {\n const x = ((gx % 8) + 8) % 8\n const y = ((gy % 8) + 8) % 8\n return BAYER8[y]![x]! / 64\n}\n\nfunction hash2(x: number, y: number) {\n const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123\n return s - Math.floor(s)\n}\n\nfunction valueNoise(x: number, y: number) {\n const ix = Math.floor(x)\n const iy = Math.floor(y)\n const fx = x - ix\n const fy = y - iy\n const a = hash2(ix, iy)\n const b = hash2(ix + 1, iy)\n const c = hash2(ix, iy + 1)\n const d = hash2(ix + 1, iy + 1)\n const ux = fx * fx * (3 - 2 * fx)\n const uy = fy * fy * (3 - 2 * fy)\n return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy\n}\n\nfunction fbm(x: number, y: number) {\n let v = 0\n let a = 0.5\n let f = 1\n for (let i = 0; i < 3; i++) {\n v += valueNoise(x * f, y * f) * a\n a *= 0.5\n f *= 2.05\n }\n return v\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: Dithered404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction cellKey(gx: number, gy: number) {\n return gx * 4096 + gy\n}\n\nfunction fireTint(field: number) {\n if (field > 0.72) return FIRE_HIGHLIGHT\n if (field > 0.4) return FIRE_FLAME\n return FIRE_EMBER\n}\n\nfunction hexRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [163, 58, 24]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n}\n\nfunction rgba(hex: string, a: number) {\n const [r, g, b] = hexRgb(hex)\n return `rgba(${r},${g},${b},${a})`\n}\n\nfunction scorchedInk(ink: string, heat: number): string {\n if (heat <= 0.02) return ink\n const [r, g, b] = hexRgb(ink)\n const h = heat > 1 ? 1 : heat\n const lum = (r + g + b) / 3\n let nr: number\n let ng: number\n let nb: number\n if (lum >= 130) {\n const k = 1 - h * 0.9\n nr = r * k\n ng = g * k * (1 - h * 0.12)\n nb = b * k * (1 - h * 0.22)\n if (h > 0.5) {\n const g2 = (h - 0.5) / 0.5\n nr = nr * (1 - g2 * 0.35) + 163 * g2 * 0.55\n ng = ng * (1 - g2 * 0.35) + 58 * g2 * 0.4\n nb = nb * (1 - g2 * 0.35) + 24 * g2 * 0.25\n }\n } else {\n nr = r * (1 - h * 0.22) + 52 * h\n ng = g * (1 - h * 0.38) + 18 * h\n nb = b * (1 - h * 0.52) + 8 * h\n if (h > 0.55) {\n const g2 = (h - 0.55) / 0.45\n nr = nr * (1 - g2 * 0.4) + 163 * g2 * 0.7\n ng = ng * (1 - g2 * 0.4) + 58 * g2 * 0.5\n nb = nb * (1 - g2 * 0.4) + 24 * g2 * 0.3\n }\n }\n return `rgb(${nr | 0},${ng | 0},${nb | 0})`\n}\n\nfunction rasterizeGlyph(\n cssW: number,\n cssH: number,\n pixelSize: number,\n dpr: number,\n dither: boolean\n): GlyphCell[] {\n const w = Math.max(1, Math.floor(cssW * dpr))\n const h = Math.max(1, Math.floor(cssH * dpr))\n const cell = Math.max(1, Math.round(pixelSize * dpr))\n const off = document.createElement(\"canvas\")\n off.width = w\n off.height = h\n const ctx = off.getContext(\"2d\", { willReadFrequently: true })\n if (!ctx) return []\n\n const family = getComputedStyle(document.body).fontFamily || \"ui-sans-serif\"\n const fontSize = Math.min(cssW * 0.54, cssH * 0.5) * dpr\n ctx.clearRect(0, 0, w, h)\n ctx.fillStyle = \"#fff\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = `800 ${fontSize}px ${family}`\n if (\"letterSpacing\" in ctx) {\n ;(\n ctx as CanvasRenderingContext2D & { letterSpacing: string }\n ).letterSpacing = `${-fontSize * 0.045}px`\n }\n ctx.fillText(\"404\", w / 2, h / 2)\n\n const data = ctx.getImageData(0, 0, w, h).data\n const cells: GlyphCell[] = []\n\n for (let y = 0; y < h; y += cell) {\n for (let x = 0; x < w; x += cell) {\n let cover = 0\n let samples = 0\n const step = Math.max(1, Math.floor(cell / 3))\n for (let sy = 0; sy < cell; sy += step) {\n for (let sx = 0; sx < cell; sx += step) {\n const px = x + sx\n const py = y + sy\n if (px >= w || py >= h) continue\n cover += data[(py * w + px) * 4 + 3]!\n samples++\n }\n }\n const alpha = samples > 0 ? cover / (samples * 255) : 0\n if (alpha < (dither ? 0.18 : 0.08)) continue\n const gx = Math.floor(x / cell)\n const gy = Math.floor(y / cell)\n if (dither && alpha < bayerAt(gx, gy) * 0.92 + 0.08) continue\n cells.push({\n homeX: x / dpr,\n homeY: y / dpr,\n gx,\n gy,\n alpha: dither ? 1 : Math.min(1, alpha * 1.15),\n })\n }\n }\n\n return cells\n}\n\nfunction spawnFromCells(\n cells: GlyphCell[],\n pixelSize: number,\n ink: string,\n cssH: number,\n assemble: boolean\n): Particle[] {\n return cells.map((cell) => {\n const fromBelow = assemble\n return {\n x: fromBelow ? cell.homeX + (Math.random() - 0.5) * 28 : cell.homeX,\n y: fromBelow ? cssH + 12 + Math.random() * 90 : cell.homeY,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: fromBelow ? -2.2 - Math.random() * 1.4 : 0,\n size: pixelSize,\n state: fromBelow ? REFORMING : SOLID,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n }\n })\n}\n\nfunction smokeFill(dark: boolean) {\n if (dark) {\n return Math.random() > 0.45 ? \"#A1A1AA\" : \"#71717A\"\n }\n return Math.random() > 0.45 ? \"#52525B\" : \"#3F3F46\"\n}\n\nfunction burnParticle(\n particles: Particle[],\n p: Particle,\n emberCount: { n: number },\n smokeCount: { n: number },\n pixelSize: number,\n maxEmbers: number,\n maxSmoke: number,\n dark: boolean\n) {\n p.state = EMBER\n p.life = 0.28 + Math.random() * (pixelSize <= 2 ? 0.28 : 0.45)\n p.size = Math.max(1, p.size * 0.55)\n p.vx = (Math.random() - 0.5) * 2.8\n p.vy = -1.2 - Math.random() * 2.4\n p.fill = fireTint(0.35 + Math.random() * 0.65)\n p.alpha = 1\n p.heat = 0\n emberCount.n++\n\n const extra =\n pixelSize <= 2\n ? Math.random() < 0.22\n ? 1\n : 0\n : 1 + Math.floor(Math.random() * 3)\n for (let i = 0; i < extra && emberCount.n < maxEmbers; i++) {\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size,\n y: p.y + (Math.random() - 0.5) * p.size,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 3.4,\n vy: -1.6 - Math.random() * 2.8,\n size: Math.max(1, p.size * (0.35 + Math.random() * 0.4)),\n state: EMBER,\n life: 0.22 + Math.random() * 0.4,\n rot: 0,\n vr: 0,\n gx: p.gx,\n gy: p.gy,\n fill: fireTint(0.25 + Math.random() * 0.75),\n alpha: 1,\n sleep: 0,\n heat: 0,\n })\n emberCount.n++\n }\n\n const puffs =\n pixelSize <= 2\n ? Math.random() < 0.55\n ? 1\n : 0\n : 1 + (Math.random() < 0.55 ? 1 : 0)\n for (let i = 0; i < puffs && smokeCount.n < maxSmoke; i++) {\n const puff = pixelSize * (1.6 + Math.random() * 1.8)\n particles.push({\n x: p.x + (Math.random() - 0.5) * p.size * 0.8,\n y: p.y + (Math.random() - 0.5) * p.size * 0.8,\n homeX: p.homeX,\n homeY: p.homeY,\n vx: (Math.random() - 0.5) * 0.55,\n vy: -0.45 - Math.random() * 0.85,\n size: puff,\n state: SMOKE,\n life: 0.75 + Math.random() * 0.95,\n rot: 0,\n vr: (Math.random() - 0.5) * 0.04,\n gx: p.gx,\n gy: p.gy,\n fill: smokeFill(dark),\n alpha: 0.42,\n sleep: 0,\n heat: 0,\n })\n smokeCount.n++\n }\n}\n\nfunction fireFieldAt(\n x: number,\n y: number,\n mx: number,\n my: number,\n r: number,\n t: number\n) {\n const dx = (x - mx) / r\n const dy = (y - my) / r\n const px = dx\n const py = dy > 0 ? dy * 1.4 : dy * 0.72\n const dist = Math.hypot(px, py)\n if (dist > 1.12) return 0\n const n = fbm(x * 0.055 + t * 1.7, y * 0.06 - t * 2.6)\n return Math.max(0, (1 - dist) * (0.38 + 0.62 * n))\n}\n\nfunction drawFireball(\n ctx: CanvasRenderingContext2D,\n mx: number,\n my: number,\n t: number,\n pixelSize: number,\n brush: number,\n dither: boolean\n) {\n const r = brush\n const minX = mx - r\n const maxX = mx + r\n const minY = my - r * 1.35\n const maxY = my + r * 0.85\n\n if (!dither) {\n ctx.save()\n ctx.globalCompositeOperation = \"lighter\"\n const glow = ctx.createRadialGradient(\n mx,\n my - r * 0.22,\n r * 0.04,\n mx,\n my - r * 0.08,\n r * 1.05\n )\n glow.addColorStop(0, rgba(FIRE_HIGHLIGHT, 0.95))\n glow.addColorStop(0.28, rgba(FIRE_FLAME, 0.72))\n glow.addColorStop(0.62, rgba(FIRE_EMBER, 0.32))\n glow.addColorStop(1, rgba(FIRE_EMBER, 0))\n ctx.fillStyle = glow\n ctx.beginPath()\n ctx.ellipse(mx, my - r * 0.18, r * 0.78, r * 1.12, 0, 0, Math.PI * 2)\n ctx.fill()\n\n const cell = 2\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const field = fireFieldAt(x + 1, y + 1, mx, my, r, t)\n if (field < 0.08) continue\n ctx.globalAlpha = Math.min(0.9, field * 1.05)\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(x, y, cell + 0.5, cell + 0.5)\n }\n }\n ctx.restore()\n return\n }\n\n const cell = Math.max(3, pixelSize)\n for (let y = minY; y <= maxY; y += cell) {\n for (let x = minX; x <= maxX; x += cell) {\n const cx = Math.floor(x / cell) * cell\n const cy = Math.floor(y / cell) * cell\n const field = fireFieldAt(cx + cell * 0.5, cy + cell * 0.5, mx, my, r, t)\n if (field < 0.1) continue\n if (\n field <\n bayerAt(Math.floor(cx / cell), Math.floor(cy / cell)) * 0.9 + 0.06\n ) {\n continue\n }\n ctx.fillStyle = fireTint(field)\n ctx.fillRect(cx, cy, cell, cell)\n }\n }\n}\n\n/**\n * Bayer-pixel 404 section — a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire.\n */\nexport function createDithered404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Dithered404Options = {}\n): Dithered404Instance | null {\n let options: Required<\n Pick<\n Dithered404Options,\n \"pixelSize\" | \"brush\" | \"interactive\" | \"dither\" | \"theme\"\n >\n > &\n Dithered404Options = {\n pixelSize: 4,\n brush: 28,\n interactive: true,\n dither: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true, desynchronized: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let hover = false\n const mouse = { x: 0, y: 0 }\n const targetMouse = { x: 0, y: 0 }\n\n let raf = 0\n let running = true\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cells: GlyphCell[] = []\n let particles: Particle[] = []\n let lastPixel = -1\n let lastDither: boolean | null = null\n let lastW = 0\n let lastH = 0\n let phase = PHASE_READY\n let resetAt = 0\n let reformAt = 0\n let lastNow = performance.now()\n const start = lastNow\n\n const inkOf = () => options.color ?? (dark ? DARK_COLOR : LIGHT_COLOR)\n\n const rebuild = (assemble: boolean) => {\n const px = Math.max(2, Math.round(options.pixelSize))\n const useDither = options.dither\n cells = rasterizeGlyph(cssW, cssH, px, dpr, useDither)\n particles = spawnFromCells(cells, px, inkOf(), cssH, assemble)\n phase = assemble ? PHASE_REFORMING : PHASE_READY\n resetAt = 0\n reformAt = assemble ? performance.now() : 0\n lastPixel = px\n lastDither = useDither\n }\n\n const startReform = (now: number, px: number, ink: string) => {\n const have = new Set()\n for (const p of particles) {\n if (p.state === EMBER || p.state === SMOKE) continue\n p.state = REFORMING\n p.size = px\n p.fill = ink\n p.vx = 0\n p.vy = 0\n p.vr = 0\n p.sleep = 0\n p.life = 1\n p.heat = 0\n have.add(cellKey(p.gx, p.gy))\n }\n for (const cell of cells) {\n if (have.has(cellKey(cell.gx, cell.gy))) continue\n particles.push({\n x: cell.homeX + (Math.random() - 0.5) * 22,\n y: cssH - px - Math.random() * 52,\n homeX: cell.homeX,\n homeY: cell.homeY,\n vx: 0,\n vy: 0,\n size: px,\n state: REFORMING,\n life: 1,\n rot: 0,\n vr: 0,\n gx: cell.gx,\n gy: cell.gy,\n fill: ink,\n alpha: cell.alpha,\n sleep: 0,\n heat: 0,\n })\n }\n phase = PHASE_REFORMING\n resetAt = 0\n reformAt = now\n }\n\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return\n cssW = w\n cssH = h\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.imageSmoothingEnabled = false\n if (w !== lastW || h !== lastH) {\n lastW = w\n lastH = h\n rebuild(false)\n }\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n ro.observe(wrap)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive || reduce) return\n const rect = wrap.getBoundingClientRect()\n targetMouse.x = e.clientX - rect.left\n targetMouse.y = e.clientY - rect.top\n if (!hover) {\n hover = true\n options.onHideCursor?.(true)\n }\n }\n const onLeave = () => {\n hover = false\n options.onHideCursor?.(false)\n }\n\n wrap.addEventListener(\"pointermove\", onMove, { passive: true })\n wrap.addEventListener(\"pointerenter\", onMove, { passive: true })\n wrap.addEventListener(\"pointerleave\", onLeave)\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(32, now - lastNow) / 16.67\n lastNow = now\n const t = (now - start) / 1000\n const { pixelSize, brush, interactive, dither } = options\n const px = Math.max(2, Math.round(pixelSize))\n const ink = inkOf()\n\n if ((px !== lastPixel || dither !== lastDither) && cssW > 0) {\n rebuild(false)\n }\n\n mouse.x += (targetMouse.x - mouse.x) * 0.28\n mouse.y += (targetMouse.y - mouse.y) * 0.28\n\n if (!reduce) {\n if (resetAt && now >= resetAt) {\n startReform(now, px, ink)\n }\n\n let solidCount = 0\n let emberCount = 0\n let smokeCount = 0\n let reformingCount = 0\n const emberBudget = { n: 0 }\n const smokeBudget = { n: 0 }\n const maxEmbers = px <= 2 ? MAX_EMBERS_FINE : MAX_EMBERS\n const maxSmoke = px <= 2 ? MAX_SMOKE_FINE : MAX_SMOKE\n\n const canBurn = interactive && hover && phase === PHASE_READY\n\n const scorchR = brush * 0.92\n const scorchR2 = scorchR * scorchR\n const burnR = brush * 0.62\n const burnR2 = burnR * burnR\n\n for (let i = particles.length - 1; i >= 0; i--) {\n const p = particles[i]!\n if (p.state === SOLID) {\n if (canBurn) {\n const dx = p.x + p.size * 0.5 - mouse.x\n const dy = p.y + p.size * 0.5 - mouse.y\n const d2 = dx * dx + dy * dy\n if (d2 < scorchR2) {\n const heat = 1 - Math.sqrt(d2) / scorchR\n p.heat = Math.min(1, p.heat + heat * CHAR_HEAT_GAIN * dt)\n if (\n p.heat > 0.58 &&\n d2 < burnR2 &&\n Math.random() < heat * 0.28 * dt\n ) {\n burnParticle(\n particles,\n p,\n emberBudget,\n smokeBudget,\n px,\n maxEmbers,\n maxSmoke,\n dark\n )\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n } else if (p.heat > 0) {\n p.heat = Math.max(0, p.heat - CHAR_COOL * dt)\n }\n if (p.state === SOLID) {\n p.fill = p.heat > 0 ? scorchedInk(ink, p.heat) : ink\n }\n }\n\n if (p.state === EMBER) {\n p.vy -= 0.09 * dt\n p.vx *= 0.94\n p.vy *= 0.97\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.life -= (px <= 2 ? 0.04 : 0.028) * dt\n p.size = Math.max(0.6, p.size * (1 - 0.02 * dt))\n if (p.life <= 0) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n emberCount++\n continue\n }\n\n if (p.state === SMOKE) {\n p.vy -= 0.018 * dt\n p.vx += Math.sin((now + p.x * 12) * 0.002) * 0.012 * dt\n p.vx *= 0.985\n p.x += p.vx * dt\n p.y += p.vy * dt\n p.size += 0.12 * dt\n p.life -= (px <= 2 ? 0.018 : 0.012) * dt\n p.alpha = Math.max(0, p.life * 0.5)\n if (p.life <= 0 || p.y + p.size < -40) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n continue\n }\n smokeCount++\n continue\n }\n\n if (p.state === REFORMING) {\n p.x += (p.homeX - p.x) * 0.16 * dt\n p.y += (p.homeY - p.y) * 0.16 * dt\n p.rot += (0 - p.rot) * 0.22 * dt\n p.vx = 0\n p.vy = 0\n p.fill = ink\n p.heat = 0\n if (Math.hypot(p.homeX - p.x, p.homeY - p.y) < 0.7) {\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n solidCount++\n } else {\n reformingCount++\n }\n continue\n }\n\n if (p.state === SOLID) {\n solidCount++\n }\n }\n\n const initialCount = cells.length || 1\n if (\n phase === PHASE_READY &&\n cells.length > 0 &&\n solidCount / initialCount < COLLAPSE_RATIO &&\n resetAt === 0\n ) {\n resetAt = now + RESET_DELAY_MS\n }\n\n if (phase === PHASE_REFORMING) {\n if (reformingCount === 0) {\n phase = PHASE_READY\n } else if (reformAt && now - reformAt > REFORM_SNAP_MS) {\n for (const p of particles) {\n if (p.state !== REFORMING) continue\n p.x = p.homeX\n p.y = p.homeY\n p.state = SOLID\n p.size = px\n p.rot = 0\n p.sleep = 0\n p.heat = 0\n p.fill = ink\n }\n phase = PHASE_READY\n }\n }\n\n if (emberCount > maxEmbers) {\n let extra = emberCount - maxEmbers\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === EMBER) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n\n if (smokeCount > maxSmoke) {\n let extra = smokeCount - maxSmoke\n for (let i = particles.length - 1; i >= 0 && extra > 0; i--) {\n if (particles[i]!.state === SMOKE) {\n const last = particles.pop()\n if (last && i < particles.length) particles[i] = last\n extra--\n }\n }\n }\n }\n\n ctx.clearRect(0, 0, cssW, cssH)\n ctx.imageSmoothingEnabled = !dither\n\n for (const p of particles) {\n if (p.state === EMBER) {\n ctx.globalAlpha = Math.max(0, Math.min(1, p.life * 1.4))\n ctx.fillStyle = p.fill\n if (!dither) {\n const glow = Math.max(1.2, p.size * 0.55)\n ctx.beginPath()\n ctx.arc(p.x + p.size * 0.5, p.y + p.size * 0.5, glow, 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n if (p.state === SMOKE) {\n ctx.globalAlpha = Math.max(0, Math.min(0.45, p.alpha))\n ctx.fillStyle = p.fill\n const half = p.size * 0.5\n if (!dither) {\n ctx.beginPath()\n ctx.arc(p.x + half, p.y + half, Math.max(1.2, half), 0, Math.PI * 2)\n ctx.fill()\n } else {\n ctx.fillRect(p.x, p.y, p.size, p.size)\n }\n continue\n }\n ctx.fillStyle = p.fill\n ctx.globalAlpha = dither ? 1 : p.alpha\n const pad = dither ? 0 : 0.35\n if (p.state === REFORMING && Math.abs(p.rot) > 0.04) {\n const half = p.size * 0.5\n ctx.save()\n ctx.translate(p.x + half, p.y + half)\n ctx.rotate(p.rot)\n ctx.fillRect(\n -half - pad,\n -half - pad,\n p.size + pad * 2,\n p.size + pad * 2\n )\n ctx.restore()\n } else {\n ctx.fillRect(p.x - pad, p.y - pad, p.size + pad * 2, p.size + pad * 2)\n }\n }\n\n ctx.globalAlpha = 1\n if (interactive && hover && !reduce) {\n drawFireball(ctx, mouse.x, mouse.y, t, px, brush, dither)\n }\n\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"pointermove\", onMove)\n wrap.removeEventListener(\"pointerenter\", onMove)\n wrap.removeEventListener(\"pointerleave\", onLeave)\n options.onHideCursor?.(false)\n },\n }\n}\n\nexport type Dithered404Props = Dithered404Options & {\n className?: string\n}\n\n/**\n * Bayer-pixel 404 section — a fireball cursor scorches and burns\n * the glyph into embers and smoke, then the type reforms.\n * `dither={false}` is a soft realistic fire.\n */\nexport function Dithered404({\n className,\n color,\n pixelSize = 4,\n brush = 28,\n interactive = true,\n dither = true,\n theme = \"auto\",\n}: Dithered404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hideCursor, setHideCursor] = useState(false)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createDithered404(wrap, canvas, {\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n pixelSize,\n brush,\n interactive,\n dither,\n theme,\n onHideCursor: setHideCursor,\n })\n }, [color, pixelSize, brush, interactive, dither, theme])\n\n return (\n \n 404\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/dithered-404.tsx" } diff --git a/public/r/registry.json b/public/r/registry.json index 94831c4..47f5599 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -243,28 +243,28 @@ "type": "registry:ui" }, { - "name": "shader-fire", - "title": "Shader Fire", + "name": "shader-anime-fire", + "title": "Shader Anime Fire", "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { - "path": "registry/shader-fire/shader-fire.tsx", + "path": "registry/shader-anime-fire/shader-anime-fire.tsx", "type": "registry:ui", - "target": "components/ui/shader-fire.tsx" + "target": "components/ui/shader-anime-fire.tsx" } ], "type": "registry:ui" }, { - "name": "shader-fire-svelte", - "title": "Shader Fire", + "name": "shader-anime-fire-svelte", + "title": "Shader Anime Fire", "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "dependencies": [], "files": [ { - "path": "registry/shader-fire/shader-fire.svelte", + "path": "registry/shader-anime-fire/shader-anime-fire.svelte", "type": "registry:file", - "target": "src/lib/components/ui/shader-fire.svelte" + "target": "src/lib/components/ui/shader-anime-fire.svelte" } ], "type": "registry:ui" diff --git a/registry.json b/registry.json index 9a353dc..82f1c61 100644 --- a/registry.json +++ b/registry.json @@ -12,7 +12,7 @@ "registry/logo-burst/registry.json", "registry/phosphor-score/registry.json", "registry/radiant-lines/registry.json", - "registry/shader-fire/registry.json", + "registry/shader-anime-fire/registry.json", "registry/shader-gradient/registry.json", "registry/shader-sky/registry.json", "registry/stretchy-footer/registry.json" diff --git a/registry/dithered-404/dithered-404-vanilla.ts b/registry/dithered-404/dithered-404-vanilla.ts index 094e4ae..5c32d71 100644 --- a/registry/dithered-404/dithered-404-vanilla.ts +++ b/registry/dithered-404/dithered-404-vanilla.ts @@ -480,7 +480,7 @@ function drawFireball( /** * Bayer-pixel 404 section — a fireball cursor scorches and burns * the glyph into embers and smoke, then the type reforms. - * `dither={false}` is a soft realistic fire, same idea as Shader Fire. + * `dither={false}` is a soft realistic fire. */ export function createDithered404( wrap: HTMLElement, diff --git a/registry/dithered-404/dithered-404.tsx b/registry/dithered-404/dithered-404.tsx index 1c109fb..61e065d 100644 --- a/registry/dithered-404/dithered-404.tsx +++ b/registry/dithered-404/dithered-404.tsx @@ -24,7 +24,7 @@ export type Dithered404Props = Dithered404Options & { /** * Bayer-pixel 404 section — a fireball cursor scorches and burns * the glyph into embers and smoke, then the type reforms. - * `dither={false}` is a soft realistic fire, same idea as Shader Fire. + * `dither={false}` is a soft realistic fire. */ export function Dithered404({ className, diff --git a/registry/shader-anime-fire/registry.json b/registry/shader-anime-fire/registry.json index 43697ff..cca7322 100644 --- a/registry/shader-anime-fire/registry.json +++ b/registry/shader-anime-fire/registry.json @@ -2,28 +2,28 @@ "$schema": "https://ui.shadcn.com/schema/registry.json", "items": [ { - "name": "shader-fire", + "name": "shader-anime-fire", "type": "registry:ui", - "title": "Shader Fire", + "title": "Shader Anime Fire", "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { - "path": "shader-fire.tsx", + "path": "shader-anime-fire.tsx", "type": "registry:ui", - "target": "components/ui/shader-fire.tsx" + "target": "components/ui/shader-anime-fire.tsx" } ] }, { - "name": "shader-fire-svelte", + "name": "shader-anime-fire-svelte", "type": "registry:ui", - "title": "Shader Fire", + "title": "Shader Anime Fire", "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom, sparks drift above, and the fire reaches for the pointer.", "files": [ { - "path": "shader-fire.svelte", + "path": "shader-anime-fire.svelte", "type": "registry:file", - "target": "src/lib/components/ui/shader-fire.svelte" + "target": "src/lib/components/ui/shader-anime-fire.svelte" } ], "dependencies": [] diff --git a/registry/shader-anime-fire/shader-anime-fire-demo.tsx b/registry/shader-anime-fire/shader-anime-fire-demo.tsx index 9014ef6..5579ef2 100644 --- a/registry/shader-anime-fire/shader-anime-fire-demo.tsx +++ b/registry/shader-anime-fire/shader-anime-fire-demo.tsx @@ -19,8 +19,8 @@ import { DEFAULT_INTENSITY, DEFAULT_SPEED, LIGHT_COLORS, - ShaderFire, -} from "@/registry/shader-fire/shader-fire" + ShaderAnimeFire, +} from "@/registry/shader-anime-fire/shader-anime-fire" function norm(hex: string) { return hex.trim().toUpperCase() @@ -36,7 +36,7 @@ function isStockPalette(colors: string[]) { return colorsEqual(colors, LIGHT_COLORS) || colorsEqual(colors, DARK_COLORS) } -export function ShaderFireDemo() { +export function ShaderAnimeFireDemo() { const theme = useHydratedTheme() const palette = theme === "dark" ? DARK_COLORS : LIGHT_COLORS @@ -75,7 +75,7 @@ export function ShaderFireDemo() { stageClassName="min-h-0 overflow-hidden p-0" > \n", "type": "registry:file", "target": "src/lib/components/ui/ascii-fluid.svelte" } diff --git a/public/r/ascii-fluid.json b/public/r/ascii-fluid.json index a8acde8..8036db9 100644 --- a/public/r/ascii-fluid.json +++ b/public/r/ascii-fluid.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-fluid/ascii-fluid.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiFluidTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type AsciiFluidOptions = {\n /**\n * Brightness ramp (sparse → dense). Default is a full letter/symbol map.\n */\n charset?: string\n /** Glyph cell size in CSS pixels. Default `12` */\n cellSize?: number\n /** Ink color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Default follows theme. */\n backgroundColor?: string\n /** Mouse trail / fluid force. Default `1` */\n force?: number\n /** How quickly trails fade (higher = vanish sooner). Default `0.05` */\n dissipation?: number\n /** Trail brush size 0–1. Default `0.55` */\n brush?: number\n /** Soft ambient swirl when idle. Default `true` */\n animate?: boolean\n /** Follow pointer. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiFluidTheme\n}\n\nexport type AsciiFluidInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Sparse → dense brightness ramp (letters + symbols). */\nexport const DEFAULT_CHARSET =\n \" .'`^\\\",:;Il!i><~+_-?][}{1)(|\\\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$\"\n\nconst LIGHT = { ink: \"#18181b\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#e4e4e7\", paper: \"#09090b\" }\n\nconst VERT = `\nattribute vec2 a_position;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_position * 0.5 + 0.5;\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\n// Velocity / dye stored biased in RGBA8: enc(v) = v * 0.05 + 0.5\nconst FRAG_SPLAT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_target;\nuniform vec2 u_point;\nuniform vec3 u_color;\nuniform float u_radius;\nuniform float u_aspect;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 p = v_uv - u_point;\n p.x *= u_aspect;\n float d = exp(-dot(p, p) / max(u_radius, 0.0001));\n vec3 base = texture2D(u_target, v_uv).xyz;\n if (u_velocityField > 0.5) {\n vec2 next = dec(base.xy) + u_color.xy * d;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(base + u_color * d, 1.0);\n }\n}\n`\n\nconst FRAG_ADVECT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform sampler2D u_source;\nuniform vec2 u_texel;\nuniform float u_dt;\nuniform float u_dissipation;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vec2 coord = v_uv - u_dt * vel * u_texel * 110.0;\n vec4 src = texture2D(u_source, clamp(coord, 0.0, 1.0));\n if (u_velocityField > 0.5) {\n vec2 next = dec(src.xy) * u_dissipation;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(src.xyz * u_dissipation, 1.0);\n }\n}\n`\n\nconst FRAG_DIVERGENCE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\n\nvoid main() {\n float L = dec(texture2D(u_velocity, v_uv - vec2(u_texel.x, 0.0)).xy).x;\n float R = dec(texture2D(u_velocity, v_uv + vec2(u_texel.x, 0.0)).xy).x;\n float B = dec(texture2D(u_velocity, v_uv - vec2(0.0, u_texel.y)).xy).y;\n float T = dec(texture2D(u_velocity, v_uv + vec2(0.0, u_texel.y)).xy).y;\n float div = 0.5 * ((R - L) + (T - B));\n gl_FragColor = vec4(div * 0.05 + 0.5, 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_PRESSURE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_divergence;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nfloat enc1(float v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n float C = dec1(texture2D(u_divergence, v_uv).x);\n float p = (L + R + B + T - C) * 0.25;\n gl_FragColor = vec4(enc1(p), 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_GRADIENT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vel -= vec2(R - L, T - B) * 0.5;\n gl_FragColor = vec4(enc(vel), 0.5, 1.0);\n}\n`\n\nconst FRAG_DISPLAY = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_dye;\nuniform sampler2D u_atlas;\nuniform vec2 u_resolution;\nuniform vec2 u_cell;\nuniform float u_charCount;\nuniform vec3 u_ink;\nuniform vec3 u_paper;\nuniform float u_time;\nuniform float u_animate;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);\n}\n\nvoid main() {\n vec2 pixel = v_uv * u_resolution;\n vec2 cell = floor(pixel / u_cell);\n vec2 cellUv = (cell + 0.5) * u_cell / u_resolution;\n\n float dens = clamp(texture2D(u_dye, cellUv).x, 0.0, 1.0);\n\n // Soft neighborhood sample for a low-res glow halo under the glyphs\n vec2 texel = u_cell / u_resolution;\n float glow =\n dens * 0.40 +\n texture2D(u_dye, cellUv + vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv + vec2(0.0, texel.y)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2(0.0, texel.y)).x * 0.15;\n glow = clamp(glow, 0.0, 1.0);\n glow = pow(glow, 1.35);\n\n float lit = dens;\n if (u_animate > 0.5) {\n float flicker = hash21(cell + floor(u_time * 10.0)) - 0.5;\n lit = clamp(lit + flicker * 0.05, 0.0, 1.0);\n }\n\n float idx = min(floor(lit * (u_charCount - 0.001)), u_charCount - 1.0);\n vec2 local = fract(pixel / u_cell);\n float u0 = (idx + local.x) / u_charCount;\n float glyph = texture2D(u_atlas, vec2(u0, local.y)).r;\n\n float alpha = glyph * smoothstep(0.02, 0.12, dens);\n\n // Paper → soft ink wash → sharp ASCII on top\n float wash = glow * 0.22;\n vec3 col = mix(u_paper, u_ink, wash);\n col = mix(col, u_ink, clamp(alpha, 0.0, 1.0));\n gl_FragColor = vec4(col, 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.1, 0.1, 0.12]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiFluidTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nfunction createProgram(\n gl: WebGLRenderingContext,\n vs: WebGLShader,\n fragSource: string\n) {\n const fs = compile(gl, gl.FRAGMENT_SHADER, fragSource)\n if (!fs) return null\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(fs)\n return null\n }\n return { program, fs }\n}\n\ntype FBO = {\n tex: WebGLTexture\n fbo: WebGLFramebuffer\n w: number\n h: number\n}\n\nfunction createFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n): FBO | null {\n const tex = gl.createTexture()\n const fbo = gl.createFramebuffer()\n if (!tex || !fbo) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n w,\n h,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n null\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)\n gl.framebufferTexture2D(\n gl.FRAMEBUFFER,\n gl.COLOR_ATTACHMENT0,\n gl.TEXTURE_2D,\n tex,\n 0\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n return { tex, fbo, w, h }\n}\n\nfunction createDoubleFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n) {\n const a = createFBO(gl, w, h, filter)\n const b = createFBO(gl, w, h, filter)\n if (!a || !b) return null\n return {\n read: a,\n write: b,\n swap() {\n const t = this.read\n this.read = this.write\n this.write = t\n },\n }\n}\n\nfunction buildAtlas(\n gl: WebGLRenderingContext,\n charset: string\n): { tex: WebGLTexture; count: number } | null {\n const count = Math.max(charset.length, 1)\n const size = 64\n const atlasCanvas = document.createElement(\"canvas\")\n atlasCanvas.width = size * count\n atlasCanvas.height = size\n const ctx = atlasCanvas.getContext(\"2d\")\n if (!ctx) return null\n ctx.fillStyle = \"#000\"\n ctx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height)\n ctx.fillStyle = \"#fff\"\n ctx.font = `700 ${Math.floor(size * 0.72)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n for (let i = 0; i < count; i++) {\n const ch = charset[i] ?? \" \"\n if (ch === \" \") continue\n ctx.fillText(ch, size * (i + 0.5), size * 0.55)\n }\n\n const tex = gl.createTexture()\n if (!tex) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n atlasCanvas\n )\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n return { tex, count }\n}\n\n/**\n * ASCII fluid background — pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n */\nexport function createAsciiFluid(\n canvas: HTMLCanvasElement,\n initial: AsciiFluidOptions = {}\n): AsciiFluidInstance | null {\n let options: Required<\n Pick<\n AsciiFluidOptions,\n | \"charset\"\n | \"cellSize\"\n | \"force\"\n | \"dissipation\"\n | \"brush\"\n | \"animate\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiFluidOptions = {\n charset: DEFAULT_CHARSET,\n cellSize: 12,\n force: 1,\n dissipation: 0.05,\n brush: 0.55,\n animate: true,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = {\n x: 0.5,\n y: 0.5,\n dx: 0,\n dy: 0,\n moved: false,\n inside: false,\n }\n let reduce = false\n let currentCharset = \"\"\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n preserveDrawingBuffer: false,\n })\n if (!gl) return null\n\n const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n if (!vs) return null\n\n const splat = createProgram(gl, vs, FRAG_SPLAT)\n const advect = createProgram(gl, vs, FRAG_ADVECT)\n const divergence = createProgram(gl, vs, FRAG_DIVERGENCE)\n const pressure = createProgram(gl, vs, FRAG_PRESSURE)\n const gradient = createProgram(gl, vs, FRAG_GRADIENT)\n const display = createProgram(gl, vs, FRAG_DISPLAY)\n if (!splat || !advect || !divergence || !pressure || !gradient || !display) {\n return null\n }\n\n const 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\n const bindQuad = (program: WebGLProgram) => {\n gl.useProgram(program)\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n }\n\n const SIM = 180\n const velocity = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const dye = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const pressureFbo = createDoubleFBO(gl, SIM, SIM, gl.NEAREST)\n const divergenceFbo = createFBO(gl, SIM, SIM, gl.NEAREST)\n if (!velocity || !dye || !pressureFbo || !divergenceFbo) return null\n\n const initialAtlas = buildAtlas(gl, options.charset)\n if (!initialAtlas) return null\n let atlas = initialAtlas\n currentCharset = options.charset\n\n const blit = (target: FBO | null) => {\n if (target) {\n gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)\n gl.viewport(0, 0, target.w, target.h)\n } else {\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n }\n\n const clearFbo = (fbo: FBO, r = 0, g = 0, b = 0) => {\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.fbo)\n gl.viewport(0, 0, fbo.w, fbo.h)\n gl.clearColor(r, g, b, 1)\n gl.clear(gl.COLOR_BUFFER_BIT)\n }\n\n clearFbo(velocity.read, 0.5, 0.5, 0.5)\n clearFbo(velocity.write, 0.5, 0.5, 0.5)\n clearFbo(dye.read)\n clearFbo(dye.write)\n\n let raf = 0\n let running = true\n let last = performance.now()\n const start = last\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const onPointer = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n mouse.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n mouse.dx = x - mouse.x\n mouse.dy = y - mouse.y\n mouse.x = x\n mouse.y = y\n mouse.moved = true\n }\n const onLeave = () => {\n mouse.inside = false\n }\n\n window.addEventListener(\"pointermove\", onPointer, { passive: true })\n const parentEl = canvas.parentElement\n parentEl?.addEventListener(\"pointerleave\", onLeave, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.033)\n last = now\n const time = (now - start) / 1000\n const p = options\n\n if (p.charset !== currentCharset) {\n const next = buildAtlas(gl, p.charset)\n if (next) {\n gl.deleteTexture(atlas.tex)\n atlas = next\n currentCharset = p.charset\n }\n }\n\n const dark = resolveDark(p.theme)\n const ink = hexToRgb(p.color ?? (dark ? DARK.ink : LIGHT.ink))\n const paper = hexToRgb(\n p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n )\n const texel = [1 / SIM, 1 / SIM] as const\n const aspect = canvas.width / Math.max(canvas.height, 1)\n const brushR = 0.00012 + Math.max(0.05, Math.min(1, p.brush)) * 0.0011\n\n if (p.interactive && mouse.moved && mouse.inside && !reduce) {\n const speed = Math.hypot(mouse.dx, mouse.dy)\n const strength = p.force * (18 + speed * 120)\n\n // Velocity trail\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n mouse.dx * strength,\n mouse.dy * strength,\n 0\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), brushR)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n // Dye trail — denser with speed so fast moves write brighter glyphs\n const dyeAmt = Math.min(1.4, 0.45 + speed * 8) * p.force\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n dyeAmt,\n 0,\n 0\n )\n gl.uniform1f(\n gl.getUniformLocation(splat.program, \"u_radius\"),\n brushR * 1.15\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n\n mouse.moved = false\n mouse.dx = 0\n mouse.dy = 0\n }\n\n // Soft ambient swirl so the field never fully dies when idle\n if (p.animate && !reduce && !mouse.inside) {\n const ax = Math.sin(time * 0.55) * 0.22\n const ay = Math.cos(time * 0.42) * 0.22\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n 0.5 + Math.sin(time * 0.23) * 0.22,\n 0.5 + Math.cos(time * 0.19) * 0.18\n )\n gl.uniform3f(gl.getUniformLocation(splat.program, \"u_color\"), ax, ay, 0)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), 0.0018)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n }\n\n if (!reduce) {\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.18, p.dissipation * 2.5)\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(divergence.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(divergence.program, \"u_velocity\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(divergence.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(divergenceFbo)\n\n clearFbo(pressureFbo.read, 0.5, 0.5, 0.5)\n clearFbo(pressureFbo.write, 0.5, 0.5, 0.5)\n bindQuad(pressure.program)\n for (let i = 0; i < 14; i++) {\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, divergenceFbo.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_divergence\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(pressure.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(pressureFbo.write)\n pressureFbo.swap()\n }\n\n bindQuad(gradient.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_velocity\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(gradient.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.22, Math.max(0.02, p.dissipation))\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n }\n\n bindQuad(display.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_dye\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, atlas.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_atlas\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(display.program, \"u_resolution\"),\n canvas.width,\n canvas.height\n )\n const cell =\n Math.max(7, p.cellSize) * Math.min(window.devicePixelRatio || 1, 2)\n gl.uniform2f(gl.getUniformLocation(display.program, \"u_cell\"), cell, cell)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_charCount\"),\n atlas.count\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_ink\"),\n ink[0],\n ink[1],\n ink[2]\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_paper\"),\n paper[0],\n paper[1],\n paper[2]\n )\n gl.uniform1f(gl.getUniformLocation(display.program, \"u_time\"), time)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_animate\"),\n p.animate && !reduce ? 1 : 0\n )\n blit(null)\n\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointer)\n parentEl?.removeEventListener(\"pointerleave\", onLeave)\n gl.deleteProgram(splat.program)\n gl.deleteProgram(advect.program)\n gl.deleteProgram(divergence.program)\n gl.deleteProgram(pressure.program)\n gl.deleteProgram(gradient.program)\n gl.deleteProgram(display.program)\n gl.deleteShader(vs)\n gl.deleteShader(splat.fs)\n gl.deleteShader(advect.fs)\n gl.deleteShader(divergence.fs)\n gl.deleteShader(pressure.fs)\n gl.deleteShader(gradient.fs)\n gl.deleteShader(display.fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(atlas.tex)\n for (const f of [\n velocity.read,\n velocity.write,\n dye.read,\n dye.write,\n pressureFbo.read,\n pressureFbo.write,\n divergenceFbo,\n ]) {\n gl.deleteTexture(f.tex)\n gl.deleteFramebuffer(f.fbo)\n }\n },\n }\n}\n\nexport type AsciiFluidProps = AsciiFluidOptions & {\n className?: string\n}\n\n/**\n * ASCII fluid background — pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n */\nexport function AsciiFluid({\n className,\n charset = DEFAULT_CHARSET,\n cellSize = 12,\n color,\n backgroundColor,\n force = 1,\n dissipation = 0.05,\n brush = 0.55,\n animate = true,\n interactive = true,\n theme = \"auto\",\n}: AsciiFluidProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createAsciiFluid(canvas, {\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n }, [\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n ])\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiFluidTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type AsciiFluidOptions = {\n /**\n * Brightness ramp (sparse → dense). Default is a full letter/symbol map.\n */\n charset?: string\n /** Glyph cell size in CSS pixels. Default `12` */\n cellSize?: number\n /** Ink color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Default follows theme. */\n backgroundColor?: string\n /** Mouse trail / fluid force. Default `1` */\n force?: number\n /** How quickly trails fade (higher = vanish sooner). Default `0.05` */\n dissipation?: number\n /** Trail brush size 0–1. Default `0.55` */\n brush?: number\n /** Soft ambient swirl when idle. Default `true` */\n animate?: boolean\n /** Follow pointer. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiFluidTheme\n}\n\nexport type AsciiFluidInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Sparse → dense brightness ramp (letters + symbols). */\nexport const DEFAULT_CHARSET =\n \" .'`^\\\",:;Il!i><~+_-?][}{1)(|\\\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$\"\n\nconst LIGHT = { ink: \"#18181b\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#e4e4e7\", paper: \"#09090b\" }\n\nconst VERT = `\nattribute vec2 a_position;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_position * 0.5 + 0.5;\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\n// Velocity / dye stored biased in RGBA8: enc(v) = v * 0.05 + 0.5\nconst FRAG_SPLAT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_target;\nuniform vec2 u_point;\nuniform vec3 u_color;\nuniform float u_radius;\nuniform float u_aspect;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 p = v_uv - u_point;\n p.x *= u_aspect;\n float d = exp(-dot(p, p) / max(u_radius, 0.0001));\n vec3 base = texture2D(u_target, v_uv).xyz;\n if (u_velocityField > 0.5) {\n vec2 next = dec(base.xy) + u_color.xy * d;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(base + u_color * d, 1.0);\n }\n}\n`\n\nconst FRAG_ADVECT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform sampler2D u_source;\nuniform vec2 u_texel;\nuniform float u_dt;\nuniform float u_dissipation;\nuniform float u_velocityField;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vec2 coord = v_uv - u_dt * vel * u_texel * 110.0;\n vec4 src = texture2D(u_source, clamp(coord, 0.0, 1.0));\n if (u_velocityField > 0.5) {\n vec2 next = dec(src.xy) * u_dissipation;\n gl_FragColor = vec4(enc(next), 0.5, 1.0);\n } else {\n gl_FragColor = vec4(src.xyz * u_dissipation, 1.0);\n }\n}\n`\n\nconst FRAG_DIVERGENCE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\n\nvoid main() {\n float L = dec(texture2D(u_velocity, v_uv - vec2(u_texel.x, 0.0)).xy).x;\n float R = dec(texture2D(u_velocity, v_uv + vec2(u_texel.x, 0.0)).xy).x;\n float B = dec(texture2D(u_velocity, v_uv - vec2(0.0, u_texel.y)).xy).y;\n float T = dec(texture2D(u_velocity, v_uv + vec2(0.0, u_texel.y)).xy).y;\n float div = 0.5 * ((R - L) + (T - B));\n gl_FragColor = vec4(div * 0.05 + 0.5, 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_PRESSURE = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_divergence;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nfloat enc1(float v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n float C = dec1(texture2D(u_divergence, v_uv).x);\n float p = (L + R + B + T - C) * 0.25;\n gl_FragColor = vec4(enc1(p), 0.0, 0.0, 1.0);\n}\n`\n\nconst FRAG_GRADIENT = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_pressure;\nuniform sampler2D u_velocity;\nuniform vec2 u_texel;\n\nfloat dec1(float e) { return (e - 0.5) / 0.05; }\nvec2 dec(vec2 e) { return (e - 0.5) / 0.05; }\nvec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }\n\nvoid main() {\n float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);\n float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);\n float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);\n float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);\n vec2 vel = dec(texture2D(u_velocity, v_uv).xy);\n vel -= vec2(R - L, T - B) * 0.5;\n gl_FragColor = vec4(enc(vel), 0.5, 1.0);\n}\n`\n\nconst FRAG_DISPLAY = `\nprecision highp float;\nvarying vec2 v_uv;\nuniform sampler2D u_dye;\nuniform sampler2D u_atlas;\nuniform vec2 u_resolution;\nuniform vec2 u_cell;\nuniform float u_charCount;\nuniform vec3 u_ink;\nuniform vec3 u_paper;\nuniform float u_time;\nuniform float u_animate;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);\n}\n\nvoid main() {\n vec2 pixel = v_uv * u_resolution;\n vec2 cell = floor(pixel / u_cell);\n vec2 cellUv = (cell + 0.5) * u_cell / u_resolution;\n\n float dens = clamp(texture2D(u_dye, cellUv).x, 0.0, 1.0);\n\n // Soft neighborhood sample for a low-res glow halo under the glyphs\n vec2 texel = u_cell / u_resolution;\n float glow =\n dens * 0.40 +\n texture2D(u_dye, cellUv + vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2( texel.x, 0.0)).x * 0.15 +\n texture2D(u_dye, cellUv + vec2(0.0, texel.y)).x * 0.15 +\n texture2D(u_dye, cellUv - vec2(0.0, texel.y)).x * 0.15;\n glow = clamp(glow, 0.0, 1.0);\n glow = pow(glow, 1.35);\n\n float lit = dens;\n if (u_animate > 0.5) {\n float flicker = hash21(cell + floor(u_time * 10.0)) - 0.5;\n lit = clamp(lit + flicker * 0.05, 0.0, 1.0);\n }\n\n float idx = min(floor(lit * (u_charCount - 0.001)), u_charCount - 1.0);\n vec2 local = fract(pixel / u_cell);\n float u0 = (idx + local.x) / u_charCount;\n float glyph = texture2D(u_atlas, vec2(u0, local.y)).r;\n\n float alpha = glyph * smoothstep(0.02, 0.12, dens);\n\n // Paper → soft ink wash → sharp ASCII on top\n float wash = glow * 0.22;\n vec3 col = mix(u_paper, u_ink, wash);\n col = mix(col, u_ink, clamp(alpha, 0.0, 1.0));\n gl_FragColor = vec4(col, 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.1, 0.1, 0.12]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiFluidTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nfunction createProgram(\n gl: WebGLRenderingContext,\n vs: WebGLShader,\n fragSource: string\n) {\n const fs = compile(gl, gl.FRAGMENT_SHADER, fragSource)\n if (!fs) return null\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"AsciiFluid: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(fs)\n return null\n }\n return { program, fs }\n}\n\ntype FBO = {\n tex: WebGLTexture\n fbo: WebGLFramebuffer\n w: number\n h: number\n}\n\nfunction createFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n): FBO | null {\n const tex = gl.createTexture()\n const fbo = gl.createFramebuffer()\n if (!tex || !fbo) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n w,\n h,\n 0,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n null\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)\n gl.framebufferTexture2D(\n gl.FRAMEBUFFER,\n gl.COLOR_ATTACHMENT0,\n gl.TEXTURE_2D,\n tex,\n 0\n )\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n return { tex, fbo, w, h }\n}\n\nfunction createDoubleFBO(\n gl: WebGLRenderingContext,\n w: number,\n h: number,\n filter: number\n) {\n const a = createFBO(gl, w, h, filter)\n const b = createFBO(gl, w, h, filter)\n if (!a || !b) return null\n return {\n read: a,\n write: b,\n swap() {\n const t = this.read\n this.read = this.write\n this.write = t\n },\n }\n}\n\nfunction buildAtlas(\n gl: WebGLRenderingContext,\n charset: string\n): { tex: WebGLTexture; count: number } | null {\n const count = Math.max(charset.length, 1)\n const size = 64\n const atlasCanvas = document.createElement(\"canvas\")\n atlasCanvas.width = size * count\n atlasCanvas.height = size\n const ctx = atlasCanvas.getContext(\"2d\")\n if (!ctx) return null\n ctx.fillStyle = \"#000\"\n ctx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height)\n ctx.fillStyle = \"#fff\"\n ctx.font = `700 ${Math.floor(size * 0.72)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n for (let i = 0; i < count; i++) {\n const ch = charset[i] ?? \" \"\n if (ch === \" \") continue\n ctx.fillText(ch, size * (i + 0.5), size * 0.55)\n }\n\n const tex = gl.createTexture()\n if (!tex) return null\n gl.bindTexture(gl.TEXTURE_2D, tex)\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n gl.texImage2D(\n gl.TEXTURE_2D,\n 0,\n gl.RGBA,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n atlasCanvas\n )\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n return { tex, count }\n}\n\n/**\n * ASCII fluid background — pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n */\nexport function createAsciiFluid(\n canvas: HTMLCanvasElement,\n initial: AsciiFluidOptions = {}\n): AsciiFluidInstance | null {\n let options: Required<\n Pick<\n AsciiFluidOptions,\n | \"charset\"\n | \"cellSize\"\n | \"force\"\n | \"dissipation\"\n | \"brush\"\n | \"animate\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiFluidOptions = {\n charset: DEFAULT_CHARSET,\n cellSize: 12,\n force: 1,\n dissipation: 0.05,\n brush: 0.55,\n animate: true,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = {\n x: 0.5,\n y: 0.5,\n dx: 0,\n dy: 0,\n moved: false,\n inside: false,\n }\n let reduce = false\n let currentCharset = \"\"\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n preserveDrawingBuffer: false,\n })\n if (!gl) return null\n\n const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n if (!vs) return null\n\n const splat = createProgram(gl, vs, FRAG_SPLAT)\n const advect = createProgram(gl, vs, FRAG_ADVECT)\n const divergence = createProgram(gl, vs, FRAG_DIVERGENCE)\n const pressure = createProgram(gl, vs, FRAG_PRESSURE)\n const gradient = createProgram(gl, vs, FRAG_GRADIENT)\n const display = createProgram(gl, vs, FRAG_DISPLAY)\n if (!splat || !advect || !divergence || !pressure || !gradient || !display) {\n return null\n }\n\n const 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\n const bindQuad = (program: WebGLProgram) => {\n gl.useProgram(program)\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n }\n\n const SIM = 180\n const velocity = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const dye = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)\n const pressureFbo = createDoubleFBO(gl, SIM, SIM, gl.NEAREST)\n const divergenceFbo = createFBO(gl, SIM, SIM, gl.NEAREST)\n if (!velocity || !dye || !pressureFbo || !divergenceFbo) return null\n\n const initialAtlas = buildAtlas(gl, options.charset)\n if (!initialAtlas) return null\n let atlas = initialAtlas\n currentCharset = options.charset\n\n const blit = (target: FBO | null) => {\n if (target) {\n gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)\n gl.viewport(0, 0, target.w, target.h)\n } else {\n gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n }\n\n const clearFbo = (fbo: FBO, r = 0, g = 0, b = 0) => {\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.fbo)\n gl.viewport(0, 0, fbo.w, fbo.h)\n gl.clearColor(r, g, b, 1)\n gl.clear(gl.COLOR_BUFFER_BIT)\n }\n\n clearFbo(velocity.read, 0.5, 0.5, 0.5)\n clearFbo(velocity.write, 0.5, 0.5, 0.5)\n clearFbo(dye.read)\n clearFbo(dye.write)\n\n let raf = 0\n let running = true\n let last = performance.now()\n const start = last\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const onPointer = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = 1 - (e.clientY - rect.top) / rect.height\n mouse.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n mouse.dx = x - mouse.x\n mouse.dy = y - mouse.y\n mouse.x = x\n mouse.y = y\n mouse.moved = true\n }\n const onLeave = () => {\n mouse.inside = false\n }\n\n window.addEventListener(\"pointermove\", onPointer, { passive: true })\n const parentEl = canvas.parentElement\n parentEl?.addEventListener(\"pointerleave\", onLeave, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.033)\n last = now\n const time = (now - start) / 1000\n const p = options\n\n if (p.charset !== currentCharset) {\n const next = buildAtlas(gl, p.charset)\n if (next) {\n gl.deleteTexture(atlas.tex)\n atlas = next\n currentCharset = p.charset\n }\n }\n\n const dark = resolveDark(p.theme)\n const ink = hexToRgb(p.color ?? (dark ? DARK.ink : LIGHT.ink))\n const paper = hexToRgb(\n p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n )\n const texel = [1 / SIM, 1 / SIM] as const\n const aspect = canvas.width / Math.max(canvas.height, 1)\n const brushR = 0.00012 + Math.max(0.05, Math.min(1, p.brush)) * 0.0011\n\n if (p.interactive && mouse.moved && mouse.inside && !reduce) {\n const speed = Math.hypot(mouse.dx, mouse.dy)\n const strength = p.force * (18 + speed * 120)\n\n // Velocity trail\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n mouse.dx * strength,\n mouse.dy * strength,\n 0\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), brushR)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n // Dye trail — denser with speed so fast moves write brighter glyphs\n const dyeAmt = Math.min(1.4, 0.45 + speed * 8) * p.force\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n mouse.x,\n mouse.y\n )\n gl.uniform3f(\n gl.getUniformLocation(splat.program, \"u_color\"),\n dyeAmt,\n 0,\n 0\n )\n gl.uniform1f(\n gl.getUniformLocation(splat.program, \"u_radius\"),\n brushR * 1.15\n )\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n\n mouse.moved = false\n mouse.dx = 0\n mouse.dy = 0\n }\n\n // Soft ambient swirl so the field never fully dies when idle\n if (p.animate && !reduce && !mouse.inside) {\n const ax = Math.sin(time * 0.55) * 0.22\n const ay = Math.cos(time * 0.42) * 0.22\n bindQuad(splat.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(splat.program, \"u_target\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(splat.program, \"u_point\"),\n 0.5 + Math.sin(time * 0.23) * 0.22,\n 0.5 + Math.cos(time * 0.19) * 0.18\n )\n gl.uniform3f(gl.getUniformLocation(splat.program, \"u_color\"), ax, ay, 0)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_radius\"), 0.0018)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_aspect\"), aspect)\n gl.uniform1f(gl.getUniformLocation(splat.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n }\n\n if (!reduce) {\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.18, p.dissipation * 2.5)\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 1)\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(divergence.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(divergence.program, \"u_velocity\"), 0)\n gl.uniform2f(\n gl.getUniformLocation(divergence.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(divergenceFbo)\n\n clearFbo(pressureFbo.read, 0.5, 0.5, 0.5)\n clearFbo(pressureFbo.write, 0.5, 0.5, 0.5)\n bindQuad(pressure.program)\n for (let i = 0; i < 14; i++) {\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, divergenceFbo.tex)\n gl.uniform1i(gl.getUniformLocation(pressure.program, \"u_divergence\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(pressure.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(pressureFbo.write)\n pressureFbo.swap()\n }\n\n bindQuad(gradient.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_pressure\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(gradient.program, \"u_velocity\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(gradient.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n blit(velocity.write)\n velocity.swap()\n\n bindQuad(advect.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_velocity\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(advect.program, \"u_source\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(advect.program, \"u_texel\"),\n texel[0],\n texel[1]\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_dt\"), dt)\n gl.uniform1f(\n gl.getUniformLocation(advect.program, \"u_dissipation\"),\n 1 - Math.min(0.22, Math.max(0.02, p.dissipation))\n )\n gl.uniform1f(gl.getUniformLocation(advect.program, \"u_velocityField\"), 0)\n blit(dye.write)\n dye.swap()\n }\n\n bindQuad(display.program)\n gl.activeTexture(gl.TEXTURE0)\n gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_dye\"), 0)\n gl.activeTexture(gl.TEXTURE1)\n gl.bindTexture(gl.TEXTURE_2D, atlas.tex)\n gl.uniform1i(gl.getUniformLocation(display.program, \"u_atlas\"), 1)\n gl.uniform2f(\n gl.getUniformLocation(display.program, \"u_resolution\"),\n canvas.width,\n canvas.height\n )\n const cell =\n Math.max(7, p.cellSize) * Math.min(window.devicePixelRatio || 1, 2)\n gl.uniform2f(gl.getUniformLocation(display.program, \"u_cell\"), cell, cell)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_charCount\"),\n atlas.count\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_ink\"),\n ink[0],\n ink[1],\n ink[2]\n )\n gl.uniform3f(\n gl.getUniformLocation(display.program, \"u_paper\"),\n paper[0],\n paper[1],\n paper[2]\n )\n gl.uniform1f(gl.getUniformLocation(display.program, \"u_time\"), time)\n gl.uniform1f(\n gl.getUniformLocation(display.program, \"u_animate\"),\n p.animate && !reduce ? 1 : 0\n )\n blit(null)\n\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointer)\n parentEl?.removeEventListener(\"pointerleave\", onLeave)\n gl.deleteProgram(splat.program)\n gl.deleteProgram(advect.program)\n gl.deleteProgram(divergence.program)\n gl.deleteProgram(pressure.program)\n gl.deleteProgram(gradient.program)\n gl.deleteProgram(display.program)\n gl.deleteShader(vs)\n gl.deleteShader(splat.fs)\n gl.deleteShader(advect.fs)\n gl.deleteShader(divergence.fs)\n gl.deleteShader(pressure.fs)\n gl.deleteShader(gradient.fs)\n gl.deleteShader(display.fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(atlas.tex)\n for (const f of [\n velocity.read,\n velocity.write,\n dye.read,\n dye.write,\n pressureFbo.read,\n pressureFbo.write,\n divergenceFbo,\n ]) {\n gl.deleteTexture(f.tex)\n gl.deleteFramebuffer(f.fbo)\n }\n },\n }\n}\n\nexport type AsciiFluidProps = AsciiFluidOptions & {\n className?: string\n}\n\n/**\n * ASCII fluid background — pointer trails leave ink that swirls and\n * quantizes to a clean brightness-mapped glyph field. Zero deps.\n */\nexport function AsciiFluid({\n className,\n charset = DEFAULT_CHARSET,\n cellSize = 12,\n color,\n backgroundColor,\n force = 1,\n dissipation = 0.05,\n brush = 0.55,\n animate = true,\n interactive = true,\n theme = \"auto\",\n}: AsciiFluidProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createAsciiFluid(canvas, {\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n })\n }, [\n charset,\n cellSize,\n color,\n backgroundColor,\n force,\n dissipation,\n brush,\n animate,\n interactive,\n theme,\n ])\n\n return (\n \n {/* Firefox can hand an opaque WebGL canvas straight to the system\n compositor, which ignores rounded clips. The mask on the root keeps\n it in the page's own layer, so the parent's radius holds. */}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-fluid.tsx" } diff --git a/public/r/shader-anime-fire-svelte.json b/public/r/shader-anime-fire-svelte.json index db46152..3afaa4b 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", + "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 429d045..4e9e06a 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 /** How many sparks drift up, 0–1. `0` turns them off. Default `0.6`. */\n embers?: 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\nexport const DEFAULT_EMBERS = 0.6\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_embers;\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' hashes — no sin(), so they stay 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 hash2(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\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// Sparks: three parallax layers of cells scrolling up, one spark in a few.\nfloat embers(vec2 uv, float aspect, float t, float plume) {\n if (u_embers <= 0.0) return 0.0;\n float climb = max(climbOf(), 0.05);\n float sum = 0.0;\n for (int i = 0; i < 3; i++) {\n float fi = float(i);\n float scale = 8.0 + fi * 7.0;\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float column = floor(p.x);\n float lane = hash(vec2(column, fi * 7.1));\n p.y -= t * (0.34 - fi * 0.06) * scale * (0.7 + 0.6 * lane);\n\n vec2 id = floor(p);\n vec2 h = hash2(id + fi * 17.3);\n float rate = u_embers * (0.16 + fi * 0.07) * (1.0 + plume * 2.5);\n if (h.x > rate) continue;\n\n vec2 c = 0.5 + (h - 0.5) * 0.45;\n c.x += sin(t * (1.4 + h.y * 2.2) + h.x * 40.0) * 0.16;\n vec2 d = fract(p) - c;\n d.y *= 0.5;\n float r = mix(0.07, 0.13, h.y) * (1.0 - fi * 0.22);\n float spark = 1.0 - smoothstep(0.0, r, length(d));\n spark *= spark;\n\n float twinkle = 0.55 + 0.45 * sin(t * (5.0 + h.y * 9.0) + h.x * 90.0);\n // Born in the flames, burnt out a little above them.\n float life = 1.0 - smoothstep(climb * 0.5, climb * 2.6, uv.y);\n life *= smoothstep(0.0, climb * 0.35, uv.y);\n sum += spark * twinkle * life * (1.0 - fi * 0.25);\n }\n return min(sum, 1.0);\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 float spark = embers(uv, aspect, t, plume);\n spark = smoothstep(0.1 + q * 0.15, 0.3 + q * 0.15, spark);\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 col = mix(col, u_dark > 0.5 ? mix(u_c1, u_c2, 0.6) : u_c0, spark);\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 fire along the bottom edge: turbulent tongues colored by heat,\n * sparks drifting up, and a warm glow that flickers with them. Theme-aware;\n * pauses off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`.\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uIntensity = u(\"u_intensity\")\n const uHeight = u(\"u_height\")\n const uEmbers = u(\"u_embers\")\n const uIgnite = u(\"u_ignite\")\n const uDark = u(\"u_dark\")\n const uDither = u(\"u_dither\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n const uBase = u(\"u_base\")\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 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 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 if (raf) 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 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(uEmbers, numberOr(options.embers, DEFAULT_EMBERS, 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 resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderAnimeFireProps = Omit & {\n className?: string\n}\n\n/**\n * Cel-shaded flames licking up from the bottom edge, with sparks drifting\n * above them. 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 embers = DEFAULT_EMBERS,\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 embers,\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 embers,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n intensity,\n height,\n embers,\n interactive,\n dither,\n pixelSize,\n theme,\n ])\n\n const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK\n\n return (\n \n {/* The canvas carries the radius itself: a WebGL layer can slip past a\n parent's rounded clip on some GPUs. */}\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 /** How many sparks drift up, 0–1. `0` turns them off. Default `0.6`. */\n embers?: 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\nexport const DEFAULT_EMBERS = 0.6\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_embers;\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' hashes — no sin(), so they stay 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 hash2(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\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// Sparks: three parallax layers of cells scrolling up, one spark in a few.\nfloat embers(vec2 uv, float aspect, float t, float plume) {\n if (u_embers <= 0.0) return 0.0;\n float climb = max(climbOf(), 0.05);\n float sum = 0.0;\n for (int i = 0; i < 3; i++) {\n float fi = float(i);\n float scale = 8.0 + fi * 7.0;\n vec2 p = vec2(uv.x * aspect, uv.y) * scale;\n float column = floor(p.x);\n float lane = hash(vec2(column, fi * 7.1));\n p.y -= t * (0.34 - fi * 0.06) * scale * (0.7 + 0.6 * lane);\n\n vec2 id = floor(p);\n vec2 h = hash2(id + fi * 17.3);\n float rate = u_embers * (0.16 + fi * 0.07) * (1.0 + plume * 2.5);\n if (h.x > rate) continue;\n\n vec2 c = 0.5 + (h - 0.5) * 0.45;\n c.x += sin(t * (1.4 + h.y * 2.2) + h.x * 40.0) * 0.16;\n vec2 d = fract(p) - c;\n d.y *= 0.5;\n float r = mix(0.07, 0.13, h.y) * (1.0 - fi * 0.22);\n float spark = 1.0 - smoothstep(0.0, r, length(d));\n spark *= spark;\n\n float twinkle = 0.55 + 0.45 * sin(t * (5.0 + h.y * 9.0) + h.x * 90.0);\n // Born in the flames, burnt out a little above them.\n float life = 1.0 - smoothstep(climb * 0.5, climb * 2.6, uv.y);\n life *= smoothstep(0.0, climb * 0.35, uv.y);\n sum += spark * twinkle * life * (1.0 - fi * 0.25);\n }\n return min(sum, 1.0);\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 float spark = embers(uv, aspect, t, plume);\n spark = smoothstep(0.1 + q * 0.15, 0.3 + q * 0.15, spark);\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 col = mix(col, u_dark > 0.5 ? mix(u_c1, u_c2, 0.6) : u_c0, spark);\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 fire along the bottom edge: turbulent tongues colored by heat,\n * sparks drifting up, and a warm glow that flickers with them. Theme-aware;\n * pauses off-screen and in hidden tabs; holds a still frame under\n * `prefers-reduced-motion`.\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderAnimeFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uIntensity = u(\"u_intensity\")\n const uHeight = u(\"u_height\")\n const uEmbers = u(\"u_embers\")\n const uIgnite = u(\"u_ignite\")\n const uDark = u(\"u_dark\")\n const uDither = u(\"u_dither\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\")]\n const uBase = u(\"u_base\")\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 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 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 if (raf) 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 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(uEmbers, numberOr(options.embers, DEFAULT_EMBERS, 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 resize()\n wake()\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n wake()\n },\n destroy() {\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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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, with sparks drifting\n * above them. 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 embers = DEFAULT_EMBERS,\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 embers,\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 embers,\n interactive,\n dither,\n pixelSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n intensity,\n height,\n embers,\n interactive,\n dither,\n pixelSize,\n theme,\n ])\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-fire-svelte.json b/public/r/shader-fire-svelte.json index 1c2d0f9..b60093a 100644 --- a/public/r/shader-fire-svelte.json +++ b/public/r/shader-fire-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/shader-fire/shader-fire.svelte", - "content": "\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-fire.svelte" } diff --git a/public/r/shader-fire.json b/public/r/shader-fire.json index f1ba1f5..13bed95 100644 --- a/public/r/shader-fire.json +++ b/public/r/shader-fire.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/shader-fire/shader-fire.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderFireOptions = {\n /** Ember / flame / highlight hex */\n colors?: string[]\n /** Rise speed. Default 0.55 */\n speed?: number\n /** Coverage 0–1. Default 0.55 */\n intensity?: number\n /** How far tongues climb 0–1. Default 0.45 */\n height?: number\n /** Local heat under the pointer. Default true */\n interactive?: boolean\n /** Ordered Bayer pixels instead of a soft wash. 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?: ShaderFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderFireInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Warm ink on paper — sienna / amber / dusty peach */\nexport const LIGHT_COLORS = [\"#9C3A24\", \"#C96A32\", \"#E6C4A0\"]\n/** Visible coals on slate — ember / flame / highlight */\nexport const DARK_COLORS = [\"#A33A18\", \"#D4682A\", \"#E8B45A\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#FCFBF9\",\n backgroundImage: [\n \"radial-gradient(80% 42% at 50% 108%, #9C3A24 0%, transparent 70%)\",\n \"radial-gradient(36% 28% at 22% 100%, #C96A32 0%, transparent 72%)\",\n \"radial-gradient(32% 24% at 78% 100%, #C96A32 0%, transparent 72%)\",\n \"radial-gradient(22% 16% at 50% 100%, #E6C4A0 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#08090C\",\n backgroundImage: [\n \"radial-gradient(80% 42% at 50% 108%, #A33A18 0%, transparent 70%)\",\n \"radial-gradient(36% 28% at 22% 100%, #D4682A 0%, transparent 72%)\",\n \"radial-gradient(32% 24% at 78% 100%, #D4682A 0%, transparent 72%)\",\n \"radial-gradient(22% 16% at 50% 100%, #E8B45A 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\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 float u_intensity;\nuniform float u_height;\nuniform float u_dark;\nuniform float u_dither;\nuniform float u_pixelSize;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec2 u_mouse;\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\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 vec2 hi = floor(c * 0.5);\n vec2 lo = mod(c, 2.0);\n return bayer2(lo) * 4.0 + bayer2(hi);\n}\n\nfloat bayer8(vec2 p) {\n vec2 c = mod(floor(p), 8.0);\n vec2 hi = floor(c * 0.5);\n vec2 lo = mod(c, 2.0);\n return (bayer4(hi) * 4.0 + bayer2(lo) + 0.5) / 64.0;\n}\n\nfloat fireField(vec2 uv, float aspect, float t) {\n float climb = mix(0.22, 0.82, clamp(u_height, 0.0, 1.0));\n\n // Column space — sway as they rise\n vec2 q = vec2(uv.x * aspect * 1.85, uv.y * 1.55 - t * 0.48);\n q.x += sin(uv.y * 7.0 + t * 1.6) * 0.045 * (0.25 + uv.y);\n\n // Two-pass domain warp — curling tongues, not blobs\n vec2 w1 = vec2(\n fbm(q * 1.7 + vec2(t * 0.28, t * 0.22)),\n fbm(q * 1.7 + vec2(-t * 0.24, t * 0.31) + 5.2)\n );\n q += (w1 - 0.5) * 0.58;\n vec2 w2 = vec2(\n fbm(q * 3.4 - vec2(t * 0.52, 0.0)),\n fbm(q * 3.4 + vec2(0.0, t * 0.44) + 2.7)\n );\n q += (w2 - 0.5) * 0.26;\n\n float shape = fbm(q * 2.35);\n float detail = fbm(q * 7.2 + vec2(0.0, t * 1.7));\n float flicker = 0.9 + 0.1 * sin(t * 13.0 + uv.x * 9.0 + shape * 6.0);\n float field = (shape * 0.68 + detail * 0.32) * flicker;\n\n // Hotter, wider base; thinner breakaways as they climb\n float taper = pow(clamp(1.0 - uv.y / max(climb, 0.05), 0.0, 1.0), 1.35);\n float core = pow(clamp(1.0 - uv.y / max(climb * 0.42, 0.04), 0.0, 1.0), 2.1);\n field = field * taper + core * shape * 0.38;\n\n float md = length((uv - u_mouse) * vec2(aspect, 1.0));\n field += exp(-md * 6.8) * 0.28 * (0.55 + 0.45 * shape);\n\n field *= smoothstep(0.0, 0.05, uv.y + 0.015);\n field = smoothstep(0.22, 0.74, field);\n\n float intensity = mix(0.34, 1.18, clamp(u_intensity, 0.0, 1.0));\n return clamp(field * intensity, 0.0, 1.0);\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 px = max(u_pixelSize, 1.0);\n\n vec2 sampleUv = gl_FragCoord.xy / u_resolution.xy;\n vec2 cell = floor(gl_FragCoord.xy / px);\n if (u_dither > 0.5) {\n sampleUv = (cell + 0.5) * px / u_resolution.xy;\n }\n\n float field = fireField(sampleUv, aspect, t);\n\n if (u_dither > 0.5) {\n float bit = step(bayer8(cell), field);\n field = bit * mix(0.45, 1.0, field);\n }\n\n vec3 ember = u_c1;\n vec3 flame = u_c2;\n vec3 highlight = u_c3;\n vec3 tint = mix(ember, flame, smoothstep(0.08, 0.48, field));\n tint = mix(tint, highlight, pow(smoothstep(0.42, 0.96, field), 1.55));\n\n vec3 paper = u_dark < 0.5\n ? vec3(0.992, 0.986, 0.978)\n : vec3(0.03, 0.032, 0.042);\n // Light: watercolor on paper — more air, less orange stain\n float cover = u_dither > 0.5\n ? field\n : field * (u_dark < 0.5 ? 0.68 : 0.84);\n vec3 col = mix(paper, tint, clamp(cover, 0.0, 1.0));\n if (u_dark < 0.5 && u_dither < 0.5) {\n float haze = field * (1.0 - field) * 0.12;\n col = mix(col, paper, haze);\n }\n\n if (u_dither < 0.5) {\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.01;\n }\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\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.77, 0.38, 0.18]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\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: ShaderFireTheme): 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 \"ShaderFire: 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 * Sparse 2D fire wash — tongues rise from the bottom behind UI.\n * Theme-aware light / dusk.\n */\nexport function createShaderFire(\n canvas: HTMLCanvasElement,\n initial: ShaderFireOptions = {}\n): ShaderFireInstance | null {\n let options: Required<\n Pick<\n ShaderFireOptions,\n | \"speed\"\n | \"intensity\"\n | \"height\"\n | \"interactive\"\n | \"dither\"\n | \"pixelSize\"\n | \"theme\"\n >\n > &\n ShaderFireOptions = {\n speed: 0.55,\n intensity: 0.55,\n height: 0.45,\n interactive: true,\n dither: false,\n pixelSize: 1,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.12 }\n const targetMouse = { x: 0.5, y: 0.12 }\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uHeight = gl.getUniformLocation(program, \"u_height\")\n const uDark = gl.getUniformLocation(program, \"u_dark\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n const uDither = gl.getUniformLocation(program, \"u_dither\")\n const uPixelSize = gl.getUniformLocation(program, \"u_pixelSize\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 <= 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\n const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)\n )\n\n mouse.x += (targetMouse.x - mouse.x) * 0.05\n mouse.y += (targetMouse.y - mouse.y) * 0.05\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const px = Math.max(1, options.pixelSize ?? 1) * dpr\n\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.55))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.55)))\n gl.uniform1f(uHeight, Math.min(1, Math.max(0, options.height ?? 0.45)))\n gl.uniform1f(uDark, dark ? 1 : 0)\n gl.uniform1f(uDither, options.dither ? 1 : 0)\n gl.uniform1f(uPixelSize, px)\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.uniform2f(\n uMouse,\n options.interactive ? mouse.x : 0.5,\n options.interactive ? mouse.y : 0.12\n )\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderFireProps = Omit & {\n className?: string\n}\n\n/**\n * Sparse 2D fire wash — tongues rise from the bottom behind UI.\n * Theme-aware light / dusk.\n */\nexport function ShaderFire({\n className,\n colors,\n speed = 0.55,\n intensity = 0.55,\n height = 0.45,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderFireProps) {\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 = createShaderFire(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 \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderFireTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderFireOptions = {\n /** Ember / flame / highlight hex */\n colors?: string[]\n /** Rise speed. Default 0.55 */\n speed?: number\n /** Coverage 0–1. Default 0.55 */\n intensity?: number\n /** How far tongues climb 0–1. Default 0.45 */\n height?: number\n /** Local heat under the pointer. Default true */\n interactive?: boolean\n /** Ordered Bayer pixels instead of a soft wash. 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?: ShaderFireTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderFireInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Warm ink on paper — sienna / amber / dusty peach */\nexport const LIGHT_COLORS = [\"#9C3A24\", \"#C96A32\", \"#E6C4A0\"]\n/** Visible coals on slate — ember / flame / highlight */\nexport const DARK_COLORS = [\"#A33A18\", \"#D4682A\", \"#E8B45A\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#FCFBF9\",\n backgroundImage: [\n \"radial-gradient(80% 42% at 50% 108%, #9C3A24 0%, transparent 70%)\",\n \"radial-gradient(36% 28% at 22% 100%, #C96A32 0%, transparent 72%)\",\n \"radial-gradient(32% 24% at 78% 100%, #C96A32 0%, transparent 72%)\",\n \"radial-gradient(22% 16% at 50% 100%, #E6C4A0 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#08090C\",\n backgroundImage: [\n \"radial-gradient(80% 42% at 50% 108%, #A33A18 0%, transparent 70%)\",\n \"radial-gradient(36% 28% at 22% 100%, #D4682A 0%, transparent 72%)\",\n \"radial-gradient(32% 24% at 78% 100%, #D4682A 0%, transparent 72%)\",\n \"radial-gradient(22% 16% at 50% 100%, #E8B45A 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\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 float u_intensity;\nuniform float u_height;\nuniform float u_dark;\nuniform float u_dither;\nuniform float u_pixelSize;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec2 u_mouse;\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\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 vec2 hi = floor(c * 0.5);\n vec2 lo = mod(c, 2.0);\n return bayer2(lo) * 4.0 + bayer2(hi);\n}\n\nfloat bayer8(vec2 p) {\n vec2 c = mod(floor(p), 8.0);\n vec2 hi = floor(c * 0.5);\n vec2 lo = mod(c, 2.0);\n return (bayer4(hi) * 4.0 + bayer2(lo) + 0.5) / 64.0;\n}\n\nfloat fireField(vec2 uv, float aspect, float t) {\n float climb = mix(0.22, 0.82, clamp(u_height, 0.0, 1.0));\n\n // Column space — sway as they rise\n vec2 q = vec2(uv.x * aspect * 1.85, uv.y * 1.55 - t * 0.48);\n q.x += sin(uv.y * 7.0 + t * 1.6) * 0.045 * (0.25 + uv.y);\n\n // Two-pass domain warp — curling tongues, not blobs\n vec2 w1 = vec2(\n fbm(q * 1.7 + vec2(t * 0.28, t * 0.22)),\n fbm(q * 1.7 + vec2(-t * 0.24, t * 0.31) + 5.2)\n );\n q += (w1 - 0.5) * 0.58;\n vec2 w2 = vec2(\n fbm(q * 3.4 - vec2(t * 0.52, 0.0)),\n fbm(q * 3.4 + vec2(0.0, t * 0.44) + 2.7)\n );\n q += (w2 - 0.5) * 0.26;\n\n float shape = fbm(q * 2.35);\n float detail = fbm(q * 7.2 + vec2(0.0, t * 1.7));\n float flicker = 0.9 + 0.1 * sin(t * 13.0 + uv.x * 9.0 + shape * 6.0);\n float field = (shape * 0.68 + detail * 0.32) * flicker;\n\n // Hotter, wider base; thinner breakaways as they climb\n float taper = pow(clamp(1.0 - uv.y / max(climb, 0.05), 0.0, 1.0), 1.35);\n float core = pow(clamp(1.0 - uv.y / max(climb * 0.42, 0.04), 0.0, 1.0), 2.1);\n field = field * taper + core * shape * 0.38;\n\n float md = length((uv - u_mouse) * vec2(aspect, 1.0));\n field += exp(-md * 6.8) * 0.28 * (0.55 + 0.45 * shape);\n\n field *= smoothstep(0.0, 0.05, uv.y + 0.015);\n field = smoothstep(0.22, 0.74, field);\n\n float intensity = mix(0.34, 1.18, clamp(u_intensity, 0.0, 1.0));\n return clamp(field * intensity, 0.0, 1.0);\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 px = max(u_pixelSize, 1.0);\n\n vec2 sampleUv = gl_FragCoord.xy / u_resolution.xy;\n vec2 cell = floor(gl_FragCoord.xy / px);\n if (u_dither > 0.5) {\n sampleUv = (cell + 0.5) * px / u_resolution.xy;\n }\n\n float field = fireField(sampleUv, aspect, t);\n\n if (u_dither > 0.5) {\n float bit = step(bayer8(cell), field);\n field = bit * mix(0.45, 1.0, field);\n }\n\n vec3 ember = u_c1;\n vec3 flame = u_c2;\n vec3 highlight = u_c3;\n vec3 tint = mix(ember, flame, smoothstep(0.08, 0.48, field));\n tint = mix(tint, highlight, pow(smoothstep(0.42, 0.96, field), 1.55));\n\n vec3 paper = u_dark < 0.5\n ? vec3(0.992, 0.986, 0.978)\n : vec3(0.03, 0.032, 0.042);\n // Light: watercolor on paper — more air, less orange stain\n float cover = u_dither > 0.5\n ? field\n : field * (u_dark < 0.5 ? 0.68 : 0.84);\n vec3 col = mix(paper, tint, clamp(cover, 0.0, 1.0));\n if (u_dark < 0.5 && u_dither < 0.5) {\n float haze = field * (1.0 - field) * 0.12;\n col = mix(col, paper, haze);\n }\n\n if (u_dither < 0.5) {\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.01;\n }\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\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.77, 0.38, 0.18]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\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: ShaderFireTheme): 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 \"ShaderFire: 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 * Sparse 2D fire wash — tongues rise from the bottom behind UI.\n * Theme-aware light / dusk.\n */\nexport function createShaderFire(\n canvas: HTMLCanvasElement,\n initial: ShaderFireOptions = {}\n): ShaderFireInstance | null {\n let options: Required<\n Pick<\n ShaderFireOptions,\n | \"speed\"\n | \"intensity\"\n | \"height\"\n | \"interactive\"\n | \"dither\"\n | \"pixelSize\"\n | \"theme\"\n >\n > &\n ShaderFireOptions = {\n speed: 0.55,\n intensity: 0.55,\n height: 0.45,\n interactive: true,\n dither: false,\n pixelSize: 1,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.12 }\n const targetMouse = { x: 0.5, y: 0.12 }\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderFire: 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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uHeight = gl.getUniformLocation(program, \"u_height\")\n const uDark = gl.getUniformLocation(program, \"u_dark\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n const uDither = gl.getUniformLocation(program, \"u_dither\")\n const uPixelSize = gl.getUniformLocation(program, \"u_pixelSize\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 <= 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\n const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)\n )\n\n mouse.x += (targetMouse.x - mouse.x) * 0.05\n mouse.y += (targetMouse.y - mouse.y) * 0.05\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const px = Math.max(1, options.pixelSize ?? 1) * dpr\n\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.55))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.55)))\n gl.uniform1f(uHeight, Math.min(1, Math.max(0, options.height ?? 0.45)))\n gl.uniform1f(uDark, dark ? 1 : 0)\n gl.uniform1f(uDither, options.dither ? 1 : 0)\n gl.uniform1f(uPixelSize, px)\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.uniform2f(\n uMouse,\n options.interactive ? mouse.x : 0.5,\n options.interactive ? mouse.y : 0.12\n )\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderFireProps = Omit & {\n className?: string\n}\n\n/**\n * Sparse 2D fire wash — tongues rise from the bottom behind UI.\n * Theme-aware light / dusk.\n */\nexport function ShaderFire({\n className,\n colors,\n speed = 0.55,\n intensity = 0.55,\n height = 0.45,\n interactive = true,\n dither = false,\n pixelSize = 1,\n theme = \"auto\",\n}: ShaderFireProps) {\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 = createShaderFire(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-fire.tsx" } diff --git a/public/r/shader-gradient-svelte.json b/public/r/shader-gradient-svelte.json index 85c97d9..0fa8244 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", + "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 62294d6..207beeb 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 = [\"#2A3BD1\", \"#7A3FD6\", \"#C43A9A\", \"#F26D5B\"]\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 float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t));\n lab = mix(u_base, lab, u_intensity * mix(0.45, 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 */\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uBlur = u(\"u_blur\")\n const uIntensity = u(\"u_intensity\")\n const uGrain = u(\"u_grain\")\n const uGrainSeed = u(\"u_grainSeed\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\"), u(\"u_c3\")]\n const uBase = u(\"u_base\")\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 last = 0\n let clock = 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf) 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 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 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 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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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 \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 = [\"#2A3BD1\", \"#7A3FD6\", \"#C43A9A\", \"#F26D5B\"]\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 float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t));\n lab = mix(u_base, lab, u_intensity * mix(0.45, 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 */\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 u = (name: string) => gl.getUniformLocation(program, name)\n const uResolution = u(\"u_resolution\")\n const uTime = u(\"u_time\")\n const uBlur = u(\"u_blur\")\n const uIntensity = u(\"u_intensity\")\n const uGrain = u(\"u_grain\")\n const uGrainSeed = u(\"u_grainSeed\")\n const uPixel = u(\"u_pixel\")\n const uPointer = u(\"u_pointer\")\n const uMouse = u(\"u_mouse\")\n const uStops = [u(\"u_c0\"), u(\"u_c1\"), u(\"u_c2\"), u(\"u_c3\")]\n const uBase = u(\"u_base\")\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 last = 0\n let clock = 0\n\n const moving = () =>\n onScreen && !mqReduce.matches && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf) 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 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 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 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 gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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-sky-svelte.json b/public/r/shader-sky-svelte.json index 1077f7d..bc4853f 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", + "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 207ee10..57f3383 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 * 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\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;\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 < 4; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec2 skyField(vec2 uv, float aspect, float t) {\n vec2 st = vec2(uv.x * aspect, uv.y);\n st -= (u_mouse - 0.5) * 0.04;\n\n float amount = clamp(u_amount, 0.0, 1.0);\n float scale = clamp(u_scale, 0.0, 1.0);\n float variation = clamp(u_variation, 0.0, 1.0);\n float cover = clamp(u_coverage, 0.0, 1.0);\n\n float freq = mix(2.65, 1.05, scale);\n vec2 wind = vec2(t * 0.18, t * 0.012);\n vec2 p = vec2(st.x, st.y * 1.18) * freq + vec2(wind.x, 0.0);\n\n vec2 q = vec2(\n fbm(p + vec2(0.0, wind.y)),\n fbm(p + vec2(5.2, 1.3) - wind.yx)\n );\n vec2 r = vec2(\n fbm(p + q * 1.35 + vec2(1.7, 9.2) + wind * 0.4),\n fbm(p + q * 1.35 + vec2(8.3, 2.8) - wind * 0.28)\n );\n\n vec2 base = p * 0.58 + r * 1.15 + wind * 0.2;\n float big = fbm(base);\n float small = fbm(p * mix(2.2, 1.35, scale) + q * 0.7 + vec2(t * 0.26, 0.1));\n float n = mix(big, mix(big, small, 0.55), variation);\n n = n * n * (3.0 - 2.0 * n);\n\n float lo = mix(0.42, 0.24, amount) - (cover - 0.5) * 0.10;\n float hi = lo + mix(0.20, 0.13, amount);\n float density = smoothstep(lo, hi, n);\n\n float far = fbm(st * freq * 0.38 + vec2(t * 0.09, 0.3) + 3.1);\n far = far * far * (3.0 - 2.0 * far);\n float farC = smoothstep(lo - 0.04, hi - 0.02, far) * mix(0.16, 0.42, amount);\n density = clamp(density + farC * (1.0 - density * 0.4), 0.0, 1.0);\n density *= mix(0.78, 1.14, cover);\n\n // Sun from upper-right: denser along the light ray = self-shadow.\n vec2 sun = vec2(0.22, 0.28);\n float nLit = fbm(base + sun);\n nLit = nLit * nLit * (3.0 - 2.0 * nLit);\n float lift = clamp((n - nLit) * 2.6, -1.0, 1.0);\n\n float mottling = fbm(p * 3.15 + q * 0.35 + vec2(2.4, -1.1));\n float under = smoothstep(0.22, 0.86, n) * (1.0 - st.y * 0.48);\n float shade = 0.40 + lift * 0.50 - under * 0.30;\n shade = mix(shade, shade * mix(0.58, 1.32, mottling), 0.52);\n shade = clamp(shade, 0.0, 1.0);\n\n return vec2(clamp(density, 0.0, 1.0), shade);\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 field = skyField(uv, aspect, t);\n float density = field.x;\n float shade = field.y;\n\n float h = pow(clamp(uv.y, 0.0, 1.0), 0.76);\n vec3 sky = mix(u_c2, u_c1, h);\n\n vec3 belly = mix(u_c4, u_c1, 0.32) * 0.76;\n vec3 body = mix(u_c4, u_c3, 0.58);\n vec3 top = mix(u_c3, vec3(1.0), 0.52);\n vec3 lit = mix(belly, body, smoothstep(0.12, 0.58, shade));\n lit = mix(lit, top, smoothstep(0.55, 0.98, shade) * 0.72);\n lit = mix(lit, belly, (1.0 - shade) * 0.22 * density);\n\n float alpha = density * mix(0.55, 1.12, clamp(u_intensity, 0.0, 1.0));\n alpha = clamp(alpha, 0.0, 1.0);\n vec3 col = mix(sky, lit, alpha);\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(dot(col, vec3(0.299, 0.587, 0.114))), 0.04);\n col += (hash(gl_FragCoord.xy) - 0.5) * 0.008;\n } else {\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.008;\n }\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 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/** 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 — clear blue with drifting clouds in light,\n * storm gray in dark. Optional window-glass film.\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 | \"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 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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uAmount = gl.getUniformLocation(program, \"u_amount\")\n const uScale = gl.getUniformLocation(program, \"u_scale\")\n const uVariation = gl.getUniformLocation(program, \"u_variation\")\n const uGlass = gl.getUniformLocation(program, \"u_glass\")\n const uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uC4 = gl.getUniformLocation(program, \"u_c4\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 <= 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\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\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const glassPx = Math.max(2, options.glassSize ?? 7) * dpr\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.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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. 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 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 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 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 glass,\n glassSize,\n theme,\n ])\n\n const fallback = skyFallback(colors, isDark)\n\n return (\n \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 * 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\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;\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 < 4; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec2 skyField(vec2 uv, float aspect, float t) {\n vec2 st = vec2(uv.x * aspect, uv.y);\n st -= (u_mouse - 0.5) * 0.04;\n\n float amount = clamp(u_amount, 0.0, 1.0);\n float scale = clamp(u_scale, 0.0, 1.0);\n float variation = clamp(u_variation, 0.0, 1.0);\n float cover = clamp(u_coverage, 0.0, 1.0);\n\n float freq = mix(2.65, 1.05, scale);\n vec2 wind = vec2(t * 0.18, t * 0.012);\n vec2 p = vec2(st.x, st.y * 1.18) * freq + vec2(wind.x, 0.0);\n\n vec2 q = vec2(\n fbm(p + vec2(0.0, wind.y)),\n fbm(p + vec2(5.2, 1.3) - wind.yx)\n );\n vec2 r = vec2(\n fbm(p + q * 1.35 + vec2(1.7, 9.2) + wind * 0.4),\n fbm(p + q * 1.35 + vec2(8.3, 2.8) - wind * 0.28)\n );\n\n vec2 base = p * 0.58 + r * 1.15 + wind * 0.2;\n float big = fbm(base);\n float small = fbm(p * mix(2.2, 1.35, scale) + q * 0.7 + vec2(t * 0.26, 0.1));\n float n = mix(big, mix(big, small, 0.55), variation);\n n = n * n * (3.0 - 2.0 * n);\n\n float lo = mix(0.42, 0.24, amount) - (cover - 0.5) * 0.10;\n float hi = lo + mix(0.20, 0.13, amount);\n float density = smoothstep(lo, hi, n);\n\n float far = fbm(st * freq * 0.38 + vec2(t * 0.09, 0.3) + 3.1);\n far = far * far * (3.0 - 2.0 * far);\n float farC = smoothstep(lo - 0.04, hi - 0.02, far) * mix(0.16, 0.42, amount);\n density = clamp(density + farC * (1.0 - density * 0.4), 0.0, 1.0);\n density *= mix(0.78, 1.14, cover);\n\n // Sun from upper-right: denser along the light ray = self-shadow.\n vec2 sun = vec2(0.22, 0.28);\n float nLit = fbm(base + sun);\n nLit = nLit * nLit * (3.0 - 2.0 * nLit);\n float lift = clamp((n - nLit) * 2.6, -1.0, 1.0);\n\n float mottling = fbm(p * 3.15 + q * 0.35 + vec2(2.4, -1.1));\n float under = smoothstep(0.22, 0.86, n) * (1.0 - st.y * 0.48);\n float shade = 0.40 + lift * 0.50 - under * 0.30;\n shade = mix(shade, shade * mix(0.58, 1.32, mottling), 0.52);\n shade = clamp(shade, 0.0, 1.0);\n\n return vec2(clamp(density, 0.0, 1.0), shade);\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 field = skyField(uv, aspect, t);\n float density = field.x;\n float shade = field.y;\n\n float h = pow(clamp(uv.y, 0.0, 1.0), 0.76);\n vec3 sky = mix(u_c2, u_c1, h);\n\n vec3 belly = mix(u_c4, u_c1, 0.32) * 0.76;\n vec3 body = mix(u_c4, u_c3, 0.58);\n vec3 top = mix(u_c3, vec3(1.0), 0.52);\n vec3 lit = mix(belly, body, smoothstep(0.12, 0.58, shade));\n lit = mix(lit, top, smoothstep(0.55, 0.98, shade) * 0.72);\n lit = mix(lit, belly, (1.0 - shade) * 0.22 * density);\n\n float alpha = density * mix(0.55, 1.12, clamp(u_intensity, 0.0, 1.0));\n alpha = clamp(alpha, 0.0, 1.0);\n vec3 col = mix(sky, lit, alpha);\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(dot(col, vec3(0.299, 0.587, 0.114))), 0.04);\n col += (hash(gl_FragCoord.xy) - 0.5) * 0.008;\n } else {\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.008;\n }\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 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/** 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 — clear blue with drifting clouds in light,\n * storm gray in dark. Optional window-glass film.\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 | \"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 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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uAmount = gl.getUniformLocation(program, \"u_amount\")\n const uScale = gl.getUniformLocation(program, \"u_scale\")\n const uVariation = gl.getUniformLocation(program, \"u_variation\")\n const uGlass = gl.getUniformLocation(program, \"u_glass\")\n const uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uC4 = gl.getUniformLocation(program, \"u_c4\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 <= 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\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\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const glassPx = Math.max(2, options.glassSize ?? 7) * dpr\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.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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. 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 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 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 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 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.svelte b/registry/ascii-fluid/ascii-fluid.svelte index a122f24..af99365 100644 --- a/registry/ascii-fluid/ascii-fluid.svelte +++ b/registry/ascii-fluid/ascii-fluid.svelte @@ -74,7 +74,10 @@ diff --git a/registry/ascii-fluid/ascii-fluid.tsx b/registry/ascii-fluid/ascii-fluid.tsx index f98ecbc..6d70499 100644 --- a/registry/ascii-fluid/ascii-fluid.tsx +++ b/registry/ascii-fluid/ascii-fluid.tsx @@ -96,11 +96,17 @@ export function AsciiFluid({ data-slot="ascii-fluid" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className )} > - + {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */} + ) } diff --git a/registry/shader-anime-fire/shader-anime-fire.svelte b/registry/shader-anime-fire/shader-anime-fire.svelte index 5b3ad32..61d805b 100644 --- a/registry/shader-anime-fire/shader-anime-fire.svelte +++ b/registry/shader-anime-fire/shader-anime-fire.svelte @@ -106,8 +106,11 @@ diff --git a/registry/shader-anime-fire/shader-anime-fire.tsx b/registry/shader-anime-fire/shader-anime-fire.tsx index 910811b..b4b1f93 100644 --- a/registry/shader-anime-fire/shader-anime-fire.tsx +++ b/registry/shader-anime-fire/shader-anime-fire.tsx @@ -33,7 +33,10 @@ export type { ShaderAnimeFireTheme, } from "./shader-anime-fire-vanilla" -export type ShaderAnimeFireProps = Omit & { +export type ShaderAnimeFireProps = Omit< + ShaderAnimeFireOptions, + "onThemeChange" +> & { className?: string } @@ -128,7 +131,7 @@ export function ShaderAnimeFire({ data-slot="shader-anime-fire" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className )} style={{ @@ -136,8 +139,9 @@ export function ShaderAnimeFire({ backgroundImage: fallback.backgroundImage, }} > - {/* The canvas carries the radius itself: a WebGL layer can slip past a - parent's rounded clip on some GPUs. */} + {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */}
Date: Tue, 29 Sep 2026 17:39:25 +0530 Subject: [PATCH 39/49] Keep the sidebar marker's arc visible in Firefox. Firefox doesn't paint the page while the docs view transition runs, so the marker's whole flight happened unseen and it just jumped. There the transition now ends at once and the new page fades in as a plain animation. --- app/docs/template.tsx | 2 +- app/globals.css | 21 +++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/app/docs/template.tsx b/app/docs/template.tsx index e65af0e..a55b4e6 100644 --- a/app/docs/template.tsx +++ b/app/docs/template.tsx @@ -11,7 +11,7 @@ import { ViewTransition } from "react" export default function DocsTemplate({ children }: { children: ReactNode }) { return ( -
{children}
+
{children}
) } diff --git a/app/globals.css b/app/globals.css index 9ec07f8..6581b9d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -119,23 +119,20 @@ } /* - * Firefox repaints a blurred snapshot every ~100ms, which stalls anything - * else moving during the transition (the sidebar marker skips its arc). - * A plain fade keeps it at full frame rate. + * Firefox stops painting the rest of the page while a view transition runs, + * so the sidebar marker's whole flight happens unseen and it just jumps. + * There the transition ends at once and the new page fades in as a plain + * animation, which leaves everything else live. */ @supports (-moz-appearance: none) { - ::view-transition-old(.page-blur) { - animation-name: page-fade-out; - } - + ::view-transition-group(.page-blur), + ::view-transition-old(.page-blur), ::view-transition-new(.page-blur) { - animation-name: page-fade-in; + animation: none; } -} -@keyframes page-fade-out { - to { - opacity: 0; + .docs-page { + animation: 420ms cubic-bezier(0.16, 1, 0.3, 1) both page-fade-in; } } From ade2c72cdfc5ebadcd1ac14ba42b623e9565ee0f Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Tue, 29 Sep 2026 17:39:25 +0530 Subject: [PATCH 40/49] Cap the sidebar marker's clock at one frame per step. Firefox stalls ~100ms swapping the page right after the marker takes off; on the real clock the spring spent that stall mid-flight and its first frame landed well down the arc. --- components/docs-sidebar.tsx | 41 ++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/components/docs-sidebar.tsx b/components/docs-sidebar.tsx index 6bc5663..b4d66aa 100644 --- a/components/docs-sidebar.tsx +++ b/components/docs-sidebar.tsx @@ -5,10 +5,15 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { arc, + cancelFrame, + frame, + frameData, LayoutGroup, motion, + time, useReducedMotion, type MotionPath, + type ValueTransition, } from "motion/react" import { DocsSidebarTrigger } from "@/components/docs-sidebar-trigger" @@ -89,6 +94,36 @@ const markPath: MotionPath = { arc({ strength: MARK_STRENGTH }).animateVisualElement(...args), } +/** The longest step, in ms, the marker's clock takes in one frame. */ +const MARK_MAX_STEP = 1000 / 60 + +/** + * Motion's frame loop, on a clock that never jumps more than + * `MARK_MAX_STEP` per frame. Firefox stalls ~100ms swapping the page right + * after the marker takes off, and on the real clock the spring spends that + * stall mid-flight, so its first frame lands well down the arc. Here a + * stalled frame just pauses the flight. + */ +const markDriver: NonNullable = (update) => { + let last: number | undefined + let clock: number | undefined + const step = ({ timestamp }: { timestamp: number }) => { + clock = + last === undefined || clock === undefined + ? timestamp + : clock + Math.min(timestamp - last, MARK_MAX_STEP) + last = timestamp + update(clock) + } + + return { + start: (keepAlive = true) => frame.update(step, keepAlive), + stop: () => cancelFrame(step), + now: () => + clock ?? (frameData.isProcessing ? frameData.timestamp : time.now()), + } +} + /** Room the marker takes before the open page's name. */ const MARK_GUTTER = "translate-x-[21px]" @@ -106,7 +141,11 @@ function ActiveMark() { layoutId="docs-sidebar-mark" aria-hidden className="pointer-events-none absolute inset-y-0 left-2 z-10 flex items-center" - transition={reduce ? { duration: 0 } : { ...markSpring, path: markPath }} + transition={ + reduce + ? { duration: 0 } + : { ...markSpring, path: markPath, driver: markDriver } + } > From e853a06dca131893c747c3139ce24f03e7aa1d14 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Tue, 29 Sep 2026 17:39:25 +0530 Subject: [PATCH 41/49] Pin the page outline where it starts instead of sliding it up on scroll. --- components/docs-toc.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/docs-toc.tsx b/components/docs-toc.tsx index 009895a..f04fc1f 100644 --- a/components/docs-toc.tsx +++ b/components/docs-toc.tsx @@ -10,7 +10,7 @@ import { DocsTocOutline } from "@/components/docs-toc-outline" */ export function DocsToc({ items }: { items: TOCItemType[] }) { return ( -
- - ) -} diff --git a/components/monitor-feeds.tsx b/components/monitor-feeds.tsx deleted file mode 100644 index db3e39a..0000000 --- a/components/monitor-feeds.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client" - -import dynamic from "next/dynamic" -import type { ComponentType, ReactNode } from "react" - -/** - * One live mount per registry item for the landing monitor. Each engine - * loads only when its feed is picked, and unmounts (freeing its WebGL - * context) when another one takes over. - */ -function feed

( - load: () => Promise> -): ComponentType

{ - return dynamic(load, { ssr: false, loading: () => null }) -} - -const AsciiFluid = feed(() => - import("@/registry/ascii-fluid/ascii-fluid").then((m) => m.AsciiFluid) -) -const AsciiLogo = feed(() => - import("@/registry/ascii-logo/ascii-logo").then((m) => m.AsciiLogo) -) -const Dithered404 = feed(() => - import("@/registry/dithered-404/dithered-404").then((m) => m.Dithered404) -) -const GooeyColorPicker = feed(() => - import("@/registry/gooey-color-picker/gooey-color-picker").then( - (m) => m.GooeyColorPicker - ) -) -const LiveOrb = feed(() => - import("@/registry/live-orb/live-orb").then((m) => m.LiveOrb) -) -const LogoBurst = feed(() => - import("@/registry/logo-burst/logo-burst").then((m) => m.LogoBurst) -) -const PhosphorScore = feed(() => - import("@/registry/phosphor-score/phosphor-score").then( - (m) => m.PhosphorScore - ) -) -const RadiantLines = feed(() => - import("@/registry/radiant-lines/radiant-lines").then((m) => m.RadiantLines) -) -const ShaderAnimeFire = feed(() => - import("@/registry/shader-anime-fire/shader-anime-fire").then( - (m) => m.ShaderAnimeFire - ) -) -const ShaderFire = feed(() => - import("@/registry/shader-fire/shader-fire").then((m) => m.ShaderFire) -) -const ShaderGradient = feed(() => - import("@/registry/shader-gradient/shader-gradient").then( - (m) => m.ShaderGradient - ) -) -const ShaderSky = feed(() => - import("@/registry/shader-sky/shader-sky").then((m) => m.ShaderSky) -) - -const FEEDS: Record ReactNode> = { - "ascii-fluid": () => , - "ascii-logo": () => , - "dithered-404": () => , - "gooey-color-picker": () => ( -

- -
- ), - "live-orb": () => ( -
- -
- ), - "logo-burst": () => , - "phosphor-score": () => , - "radiant-lines": () => , - "shader-anime-fire": () => , - "shader-fire": () => , - "shader-gradient": () => , - "shader-sky": () => , -} - -/** Renders the live feed for a registry item, or `fallback` if it has none. */ -export function MonitorFeed({ - slug, - fallback, -}: { - slug: string - fallback: ReactNode -}) { - const render = FEEDS[slug] - return render ? render() : fallback -} diff --git a/components/site-header.tsx b/components/site-header.tsx index ecb0cca..8dcf255 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -20,7 +20,7 @@ export function SiteHeader({ githubStars }: { githubStars?: number | null }) { return (
diff --git a/lib/tree.ts b/lib/tree.ts index 7854645..cebb04e 100644 --- a/lib/tree.ts +++ b/lib/tree.ts @@ -29,34 +29,3 @@ export function flattenPageTree(root: Root): PageTreeItem[] { root.children.forEach(visit) return items } - -export interface PageTreeSection { - /** Separator label from `meta.json`, or `null` for pages before the first one. */ - name: string | null - items: PageTreeItem[] -} - -/** Pages grouped under the `---Label---` separators they follow. */ -export function sectionPageTree(root: Root): PageTreeSection[] { - const sections: PageTreeSection[] = [{ name: null, items: [] }] - - function visit(node: Root["children"][number]) { - if (node.type === "separator") { - const name = extractName(node) - if (name) sections.push({ name, items: [] }) - } else if (node.type === "page" && !node.external) { - sections.at(-1)!.items.push({ title: extractName(node), url: node.url }) - } else if (node.type === "folder") { - if (node.index) { - sections.at(-1)!.items.push({ - title: extractName(node.index), - url: node.index.url, - }) - } - node.children.forEach(visit) - } - } - - root.children.forEach(visit) - return sections.filter((section) => section.items.length > 0) -} diff --git a/next.config.mjs b/next.config.mjs index 7ebb9ad..a2e64f3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,6 +11,12 @@ const config = { }, async redirects() { return [ + // Temporary until the landing page lands at `/`. + { + source: "/", + destination: "/docs/getting-started", + permanent: false, + }, { source: "/docs", destination: "/docs/getting-started", From 2e7e3a0d7052f732d25fcf1ecd99132742a0cc6e Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 22:56:24 +0530 Subject: [PATCH 43/49] Rebuild the published Shader Sky and Stretchy Footer items. public/r still carried the versions from before lightning and the flip and rotate options, so the registry tests failed. --- public/r/shader-sky-svelte.json | 2 +- public/r/shader-sky.json | 2 +- public/r/stretchy-footer-svelte.json | 2 +- public/r/stretchy-footer.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/r/shader-sky-svelte.json b/public/r/shader-sky-svelte.json index 2ab3d8c..afefadd 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 7c7dae5..001eab8 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\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;\n\n// Where the horizon sits below the frame; the gap sets how steeply the\n// cloud deck recedes (near clouds overhead, small hazy ones low down).\nconst float HORIZON = -0.38;\n// Sun just past the top-right corner, in frame units.\nconst vec2 SUN = vec2(0.36, 1.06);\n\n// Sin-free hash — stays stable at large coordinates 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 * f * (f * (f * 6.0 - 15.0) + 10.0);\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 return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nconst mat2 ROT = mat2(1.6, 1.2, -1.2, 1.6);\n\nfloat fbm5(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n for (int i = 0; i < 5; i++) {\n v += a * noise(p);\n p = ROT * p + 7.3;\n a *= 0.5;\n }\n return v / 0.96875;\n}\n\nfloat fbm3(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n for (int i = 0; i < 3; i++) {\n v += a * noise(p);\n p = ROT * p + 3.1;\n a *= 0.5;\n }\n return v / 0.875;\n}\n\nfloat luma(vec3 c) {\n return dot(c, vec3(0.2126, 0.7152, 0.0722));\n}\n\n// Cloud-deck coordinates: frame point -> where its ray meets the deck.\n// Returns (x, depth) on the plane; depth grows toward the horizon.\nvec2 deck(vec2 frame, float height) {\n float rise = max(frame.y - HORIZON, 0.02);\n return vec2(frame.x, 1.0) * (height / rise);\n}\n\nfloat cloudThreshold() {\n return mix(0.62, 0.44, u_amount) - (u_coverage - 0.5) * 0.12;\n}\n\n// Raw cumulus field at an already-warped point. Distinct clouds come from\n// a low-frequency clump term splitting the deck.\nfloat cloudField(vec2 p) {\n float base = fbm5(p);\n float clump = noise(p * mix(0.22, 0.5, u_amount) + 4.2);\n return base + (clump - 0.5) * mix(0.55, 0.3, u_amount);\n}\n\nfloat cloudThickness(float d, float th) {\n return smoothstep(th, th + mix(0.1, 0.2, u_coverage), 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 // Frame space: x centred and aspect-true, y 0 at the bottom edge.\n vec2 frame = vec2((uv.x - 0.5) * aspect, uv.y);\n vec2 look = (u_mouse - 0.5) * vec2(0.08, 0.035);\n vec2 view = frame + look;\n\n // Storm palettes (cloud darker than the horizon) light tops toward the\n // sky instead of toward white, and mute the sun.\n float storm = smoothstep(0.02, -0.12, luma(u_c3) - luma(u_c2));\n float sunAmt = (1.0 - storm * 0.8) * smoothstep(0.25, 0.8, luma(u_c3));\n\n // --- Sky ---------------------------------------------------------------\n float h = clamp(uv.y, 0.0, 1.0);\n vec3 sky = mix(u_c2, u_c1, pow(h, 0.72));\n vec3 haze = mix(u_c2, vec3(1.0), 0.28 * luma(u_c2));\n sky = mix(sky, haze, pow(1.0 - h, 3.0) * 0.55);\n\n vec2 sunFrame = vec2(SUN.x * aspect, SUN.y);\n float sunDist = length(view - sunFrame);\n vec3 sunCol = mix(vec3(1.0, 0.96, 0.86), u_c3, 0.25);\n float glow = exp(-sunDist * 2.4) * 0.34 + exp(-sunDist * 9.0) * 0.3;\n sky += sunCol * glow * sunAmt;\n\n // --- High cirrus: a slower, farther deck for parallax ------------------\n vec2 hi = deck(view, 2.6) * mix(1.1, 0.55, u_scale);\n hi += vec2(t * 0.16, -t * 0.05);\n float streak = fbm3(vec2(hi.x * 0.55 + hi.y * 0.25, hi.y * 2.6));\n float cirrus = smoothstep(0.56, 0.86, streak) * smoothstep(-0.1, 0.5, h);\n cirrus *= mix(0.1, 0.34, u_variation) * (1.0 - storm * 0.6);\n sky = mix(sky, mix(haze, vec3(1.0), 0.5 * (1.0 - storm)), cirrus);\n\n // --- Cumulus deck ------------------------------------------------------\n float freq = mix(3.4, 1.3, u_scale);\n vec2 plane = deck(view, 1.0);\n float depth = plane.y;\n // Wind carries clouds sideways and slowly toward the viewer.\n vec2 wind = vec2(t * 0.34, -t * 0.22);\n vec2 q = plane * freq + wind;\n\n // Far clouds shrink on screen, so soften their edges to stay clean.\n float edge = 0.012 + 0.014 * depth;\n // A gentle warp gives the masses lobes without smearing them into paint.\n vec2 w = vec2(fbm3(q * 0.45 + vec2(1.7, 9.2) + t * 0.05),\n fbm3(q * 0.45 + vec2(8.3, 2.8) - t * 0.04)) - 0.5;\n vec2 p = q + w * 0.9;\n\n float th = cloudThreshold();\n float d = cloudField(p);\n // Billowy edges: small puffs eat into the rim, never the core.\n float puff = fbm3(p * 3.4 + vec2(t * 0.12, 0.0));\n d -= (1.0 - smoothstep(th - 0.05, th + 0.2, d)) * (puff - 0.45) * mix(0.12, 0.4, u_variation);\n\n float mask = smoothstep(th, th + edge, d);\n float thick = cloudThickness(d, th);\n\n // Compare the cloud here with the cloud toward the sun: thinning ahead\n // means a sun-facing flank (lit), thickening means the far side (shade),\n // and a flat interior settles on the body tone.\n // The warp barely changes over these short steps, so reuse it.\n vec2 toSun = normalize(vec2(0.8, -0.6)) * 0.14;\n float ahead = cloudThickness(cloudField(p + toSun), th) * 0.45;\n ahead += cloudThickness(cloudField(p + toSun * 2.4), th) * 0.35;\n ahead += cloudThickness(cloudField(p + toSun * 4.0), th) * 0.2;\n // Soft lobes inside the body catch light so big clouds never go flat.\n float lobes = fbm3(q * 2.3 + vec2(11.0 + t * 0.1, 4.0));\n float light = 0.63 + (thick - ahead) * 1.1 - thick * 0.08;\n light += (lobes - 0.45) * 0.32 * thick;\n light = clamp(light, 0.0, 1.0);\n\n // Painterly tone bands: shadow -> body -> sunlit.\n vec3 shadowCol = mix(u_c4, u_c1, mix(0.42, 0.1, storm));\n shadowCol *= mix(0.92, 0.78, storm);\n vec3 bodyCol = mix(u_c4, u_c3, 0.55);\n vec3 litCol = mix(u_c3, vec3(1.0), 0.45 * luma(u_c3));\n litCol = mix(litCol, mix(u_c3, u_c2, 0.28), storm);\n litCol = mix(litCol, litCol * sunCol, sunAmt * 0.6);\n\n float b1 = smoothstep(0.3, 0.44, light);\n float b2 = smoothstep(0.6, 0.72, light);\n vec3 cloud = mix(shadowCol, bodyCol, b1);\n cloud = mix(cloud, litCol, b2);\n\n // Silver lining where thin edges face the sun.\n float rim = mask * (1.0 - thick) * exp(-sunDist * 1.1);\n cloud += mix(sunCol, u_c2, storm) * rim * 0.55 * (0.4 + sunAmt);\n\n // Lightning, lit from inside the deck.\n if (u_flash > 0.0) {\n vec2 fp = vec2(u_flashPos.x * aspect, u_flashPos.y);\n float bloom = exp(-dot(view - fp, view - fp) * 3.2);\n vec3 bolt = vec3(0.86, 0.9, 1.0);\n cloud += bolt * u_flash * bloom * (0.25 + thick * 0.95);\n sky += bolt * u_flash * (0.05 + bloom * 0.12);\n }\n\n // Distance haze swallows far clouds into the horizon.\n float fog = smoothstep(1.3, 3.4, depth);\n cloud = mix(cloud, haze, fog * 0.7);\n\n float alpha = mask * mix(0.62, 1.0, u_intensity) * (1.0 - fog * 0.45);\n vec3 col = mix(sky, cloud, alpha);\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 col += (hash(gl_FragCoord.xy) - 0.5) * 0.008;\n } else {\n col += (hash(gl_FragCoord.xy + fract(u_time) * 91.0) - 0.5) * 0.01;\n }\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 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/** 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 — clear blue with drifting clouds in light,\n * storm gray in dark. Optional window-glass film.\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uAmount = gl.getUniformLocation(program, \"u_amount\")\n const uScale = gl.getUniformLocation(program, \"u_scale\")\n const uVariation = gl.getUniformLocation(program, \"u_variation\")\n const uGlass = gl.getUniformLocation(program, \"u_glass\")\n const uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uC4 = gl.getUniformLocation(program, \"u_c4\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n const uFlash = gl.getUniformLocation(program, \"u_flash\")\n const uFlashPos = gl.getUniformLocation(program, \"u_flashPos\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 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 }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\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\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const glassPx = Math.max(2, options.glassSize ?? 7) * dpr\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(now))\n gl.uniform2f(uFlashPos, strike.x, strike.y)\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\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.\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 const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const 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 null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"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 null\n }\n gl.useProgram(program)\n\n const 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 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 const uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uAmount = gl.getUniformLocation(program, \"u_amount\")\n const uScale = gl.getUniformLocation(program, \"u_scale\")\n const uVariation = gl.getUniformLocation(program, \"u_variation\")\n const uGlass = gl.getUniformLocation(program, \"u_glass\")\n const uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uC4 = gl.getUniformLocation(program, \"u_c4\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n const uFlash = gl.getUniformLocation(program, \"u_flash\")\n const uFlashPos = gl.getUniformLocation(program, \"u_flashPos\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 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 // 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 gl.viewport(0, 0, canvas.width, canvas.height)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\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 }\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 }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\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\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(now))\n gl.uniform2f(uFlashPos, strike.x, strike.y)\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n gl.deleteTexture(noiseTex)\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/public/r/stretchy-footer-svelte.json b/public/r/stretchy-footer-svelte.json index d472ed2..b5ec07f 100644 --- a/public/r/stretchy-footer-svelte.json +++ b/public/r/stretchy-footer-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/stretchy-footer/stretchy-footer.svelte", - "content": "\n\n\n\n{#snippet aurora()}\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n \n
\n {@render children?.()}\n
\n \n {@render aurora()}\n \n{/if}\n", + "content": "\n\n\n\n{#snippet aurora()}\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n \n
\n {@render children?.()}\n
\n \n {@render aurora()}\n \n{/if}\n", "type": "registry:file", "target": "src/lib/components/ui/stretchy-footer.svelte" } diff --git a/public/r/stretchy-footer.json b/public/r/stretchy-footer.json index e42eeb7..ca45b46 100644 --- a/public/r/stretchy-footer.json +++ b/public/r/stretchy-footer.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/stretchy-footer/stretchy-footer.tsx", - "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\n/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */\nexport const DEFAULT_COLORS = [\n \"#FF1AE6\",\n \"#FF2E5E\",\n \"#FF8A1A\",\n \"#FFC81F\",\n \"#E2E6EC\",\n \"#8DB6E6\",\n \"#2F74DC\",\n \"#1459E3\",\n \"#1C2566\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 9\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 16\n/** White floor bloom opacity (0–1). Off by default. */\nexport const DEFAULT_GLOW = 0\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Edge columns stand this share of the centre one; the rest step evenly. */\nconst EDGE_COLUMN = 0.55\n/** The top of each column fades in over this share of its height. */\nconst TOP_FADE = 0.12\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /**\n * Gradient stops from the top of each column down to the floor, any CSS\n * colors. Every column carries the whole gradient, squeezed to its height.\n */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns, stepping down from the middle. Default `9`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `16`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0`. */\n glow?: number\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/** Stepped pyramid: the middle column is tallest, each step out is shorter. */\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return 1 - (1 - EDGE_COLUMN) * Math.abs(t)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\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/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction — a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\nconst STRIP = 256\n\n/**\n * One column's gradient, `colors` spread from just under the top to the\n * floor, with the top fading in. Columns stretch it to their own height.\n */\nfunction makeStrip(colors: string[]) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = STRIP\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n const bands = g.createLinearGradient(0, 0, 0, STRIP)\n colors.forEach((color, i) => {\n const at = colors.length > 1 ? i / (colors.length - 1) : 1\n bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color)\n })\n g.fillStyle = bands\n g.fillRect(0, 0, 1, STRIP)\n const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, STRIP)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while a stepped pyramid of blurred gradient\n * columns stretches up from the floor; each column has its own spring, so the edge wobbles as it\n * snaps back. Idle unless stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96))\n\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strip = document.createElement(\"canvas\")\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = columnScale(i, count)\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrip = () => {\n strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway — half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(stretch)\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n ctx!.drawImage(\n strip,\n 0,\n STRIP - 1,\n 1,\n 1,\n x0,\n floor,\n x1 - x0,\n bottom - floor\n )\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const max = maxOf()\n const target = forced ?? rubberBand(distance, max)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrip()\n rebuildColumns()\n layout()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrip()\n if (countOf() !== columns.length) rebuildColumns()\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n { maxStretch, colors, stiffness, damping, columns, blur, glow, demoId }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n demoId,\n })\n }, [maxStretch, colors, stiffness, damping, columns, blur, glow, demoId])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport — the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", + "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\n/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */\nexport const DEFAULT_COLORS = [\n \"#FF1AE6\",\n \"#FF2E5E\",\n \"#FF8A1A\",\n \"#FFC81F\",\n \"#E2E6EC\",\n \"#8DB6E6\",\n \"#2F74DC\",\n \"#1459E3\",\n \"#1C2566\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 9\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 16\n/** White floor bloom opacity (0–1). Off by default. */\nexport const DEFAULT_GLOW = 0\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Edge columns stand this share of the centre one; the rest step evenly. */\nconst EDGE_COLUMN = 0.55\n/** The top of each column fades in over this share of its height. */\nconst TOP_FADE = 0.12\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /**\n * Gradient stops from the top of each column down to the floor, any CSS\n * colors. Every column carries the whole gradient, squeezed to its height.\n */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns, stepping down from the middle. Default `9`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `16`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0`. */\n glow?: number\n /**\n * Invert the pyramid: the edge columns stand tallest and the middle\n * dips, so the wobble ripples in from the sides. Default `false`.\n */\n flip?: boolean\n /**\n * `180` turns the aurora upside down in place: the columns hang from the\n * lifted page's edge and the gradient runs floor to top. Default `0`.\n */\n rotate?: 0 | 180\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/**\n * Stepped pyramid: the middle column is tallest, each step out is shorter.\n * Flipped, the edges are tallest and each step in is shorter.\n */\nexport function columnScale(\n index: number,\n count: number,\n flip = false\n): number {\n if (count <= 1) return 1\n const t = Math.abs((index / (count - 1)) * 2 - 1)\n return 1 - (1 - EDGE_COLUMN) * (flip ? 1 - t : t)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\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/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction — a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\nconst STRIP = 256\n\n/**\n * One column's gradient, `colors` spread from just under the top to the\n * floor, with the top fading in. Columns stretch it to their own height.\n */\nfunction makeStrip(colors: string[]) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = STRIP\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n const bands = g.createLinearGradient(0, 0, 0, STRIP)\n colors.forEach((color, i) => {\n const at = colors.length > 1 ? i / (colors.length - 1) : 1\n bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color)\n })\n g.fillStyle = bands\n g.fillRect(0, 0, 1, STRIP)\n const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, STRIP)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while a stepped pyramid of blurred gradient\n * columns stretches up from the floor (a valley with `flip`, hanging from\n * the page with `rotate: 180`); each column\n * has its own spring, so the edge wobbles as it snaps back. Idle unless\n * stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96))\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strip = document.createElement(\"canvas\")\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = columnScale(i, count, options.flip)\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrip = () => {\n strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway — half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(stretch)\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n // Rotated, spin the drawing 180° about the gap between the lifted page\n // and the floor, so the columns hang from the page's edge.\n const rotated = options.rotate === 180\n if (rotated) {\n ctx!.setTransform(-1, 0, 0, -1, canvas.width, 2 * floor - stretch * scale)\n }\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n // The run-off under the floor would land on the page once rotated.\n if (rotated) continue\n ctx!.drawImage(\n strip,\n 0,\n STRIP - 1,\n 1,\n 1,\n x0,\n floor,\n x1 - x0,\n bottom - floor\n )\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const max = maxOf()\n const target = forced ?? rubberBand(distance, max)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrip()\n rebuildColumns()\n layout()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n const prevFlip = Boolean(options.flip)\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrip()\n if (countOf() !== columns.length || Boolean(options.flip) !== prevFlip) {\n rebuildColumns()\n }\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n flip = false,\n rotate = 0,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n {\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n demoId,\n }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n demoId,\n })\n }, [\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n demoId,\n ])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport — the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/stretchy-footer.tsx" } From 57105f5416676f1149c9974c0731afb08a1f76c6 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Thu, 1 Oct 2026 14:16:26 +0530 Subject: [PATCH 44/49] Add Typewriter. Text is typed onto the page by a typeball riding a rail that spans the frame. For every letter the ball spins and tilts that glyph to the front, strikes, and steps one cell right; at the margin it whirls home along the rail and drops a line, and the paper feeds up when the page fills. With loop on, the finished sheet is pulled out and typed again. Ink, rail and ball all use currentColor, the text sits in the mono font at the inherited size, and words wrap to the frame or to `columns`. Optional humanized rhythm, per-letter jitter, scale ticks and synthesized key, bell and return sounds. Screen readers get the whole text, reduced motion prints the page at once, and typing pauses off-screen. Ships for React and Svelte with a docs page, demo, OG card and skill entry. --- .cursor/skills/23rd/SKILL.md | 9 +- .cursor/skills/23rd/apis.md | 23 + .cursor/skills/23rd/components.md | 26 + .cursor/skills/23rd/recipes.md | 15 + README.md | 1 + content/docs/components/meta.json | 3 +- content/docs/components/typewriter.mdx | 92 ++ content/docs/getting-started.mdx | 4 +- .../og/docs/components/typewriter/image.png | Bin 0 -> 45430 bytes public/r/registry.json | 26 + public/r/typewriter-svelte.json | 15 + public/r/typewriter.json | 15 + registry.json | 3 +- registry/typewriter/registry.json | 31 + registry/typewriter/typewriter-demo.tsx | 150 +++ registry/typewriter/typewriter-vanilla.ts | 1191 +++++++++++++++++ registry/typewriter/typewriter.svelte | 88 ++ registry/typewriter/typewriter.tsx | 124 ++ skills/23rd/SKILL.md | 9 +- skills/23rd/apis.md | 23 + skills/23rd/components.md | 26 + skills/23rd/recipes.md | 15 + 22 files changed, 1881 insertions(+), 8 deletions(-) create mode 100644 content/docs/components/typewriter.mdx create mode 100644 public/og/docs/components/typewriter/image.png create mode 100644 public/r/typewriter-svelte.json create mode 100644 public/r/typewriter.json create mode 100644 registry/typewriter/registry.json create mode 100644 registry/typewriter/typewriter-demo.tsx create mode 100644 registry/typewriter/typewriter-vanilla.ts create mode 100644 registry/typewriter/typewriter.svelte create mode 100644 registry/typewriter/typewriter.tsx diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index bf6563c..94737ec 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -3,8 +3,8 @@ name: 23rd description: > Install, choose, and compose 23rd shadcn/ui registry components for React and Svelte 5. Use when the user wants a UI component, background, shader, WebGL - wash, hero atmosphere, footer, 404, color picker, ASCII effect, starfield, - orb, or page-tilt from the 23rd registry (@23rd, 23rd.dev, radiumcoders/23rd.dev), + wash, hero atmosphere, footer, 404, color picker, typewriter text, ASCII effect, + starfield, orb, or page-tilt from the 23rd registry (@23rd, 23rd.dev, radiumcoders/23rd.dev), or when adding a new component to this registry. Covers install commands, import paths, props, theming, and when not to use each item. metadata: @@ -91,6 +91,7 @@ flowchart TD pick -->|Full page 404| d404[dithered-404] pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} + pick -->|Type text out letter by letter| tw[typewriter] atmo -->|Quiet wash| sg[shader-gradient] atmo -->|Cel-shaded fire from the bottom| saf[shader-anime-fire] atmo -->|Soft fire wash from the bottom| sf[shader-fire] @@ -120,6 +121,7 @@ Disambiguation that agents get wrong: | ASCII letters that shove, scatter, and fall | `ascii-logo` | `ascii-fluid` (trails, not a wordmark) | | CRT notation, phosphor, staves | `phosphor-score` | `shader-sky` | | A face / orb / mascot | `live-orb` | `logo-burst` | +| Typing effect, typewriter, text that types itself | `typewriter` | `ascii-logo` (a wordmark, not prose) | There is one variant enum in the whole registry: `LiveOrb` `variant` is `"white" | "black" | "webgl" | "custom"`. Nothing else has `variant`. @@ -140,6 +142,8 @@ Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `Logo `Folio` and `StretchyFooter` are the scroller by default. Put the page in `children`. For a real document, set `windowScroll` and mark the tilting or lifting element (`data-folio-page` or `data-stretchy-page`). +`Typewriter` fills its parent like a background but is content: it types `text` in the mono font, inherits the font size, and inks in `currentColor`. Give the parent a height; the paper feeds up when the page fills. + `GooeyColorPicker` is an inline control. It opens upward from the trigger. Give it room (`overflow-visible`); do not clip it in `overflow-hidden`. `LiveOrb` is a fixed square (`size`, default `280`). It does not fill the viewport. The body stays put; only the eyes move. @@ -182,6 +186,7 @@ Categories match `content/docs/components/meta.json`. | `dithered-404` | Pages | Bayer 404 burned by a fireball cursor, then reforms | React + Svelte | | `folio` | Sections | Page leans on scroll, then springs flat | React + Svelte | | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | React + Svelte | +| `typewriter` | Components | A typeball on a rail types text letter by letter, returns at the margin | React + Svelte | ## Minimal installs that must be right diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index ecb1489..0ae5470 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -362,6 +362,29 @@ type FolioPlayDetail = { React exports `playFolioDemo`, `FOLIO_PLAY` (`"folio:play"`), `applyFolioFrame`. The Svelte component listens for that window event. Reduced motion: no tilt, no blur. +## typewriter + +```ts +type TypewriterProps = { + text?: string // DEFAULT_TEXT; "\n" breaks a line + speed?: number // 14 characters per second, clamped 1–120 + humanize?: boolean // true + loop?: boolean // true + hold?: number // 2600 ms on the finished page before looping + startDelay?: number // 600 ms before the first keystroke + columns?: number // 0 = fit the frame; never wider than the frame + ticks?: boolean // true + jitter?: number // 0.3, 0–1 + sound?: boolean // false + onDone?: () => void // each time the last character lands + className?: string +} +``` + +Also exported: `DEFAULT_TEXT`, `DEFAULT_SPEED`, `DEFAULT_HOLD`, `DEFAULT_START_DELAY`, `DEFAULT_JITTER`, `layoutTypewriter(text, columns)`, types `TypewriterStroke`, `TypewriterOptions`, `TypewriterInstance`. + +Vanilla `TypewriterInstance`: `{ setOptions, restart, destroy }`. Not returned to app code by the React component; remount with a `key` to type again. + ## Events that are not props | Name | Constant | Who listens | diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index efd8c7a..e579b00 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -385,3 +385,29 @@ Controlled when `value` is not `undefined`. Otherwise uncontrolled, seeded from `GooeyColor` is `{ h: 0–360, s: 0–100, l: 0–100, a: 0–1 }`. Strings accept hex (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) and `hsl` / `hsla`. `onChange` second arg is `hsla(...)` from `toCss`. EyeDropper appears only when `window.EyeDropper` exists; it is not a prop. Pitfalls: the panel overflows the trigger. Do not put it in `overflow-hidden`. React also exports `parseColor`. No `className` on Svelte — use `class`. + +## typewriter + +Component. Text typed onto the page by a typeball that rides a full-width rail. For each letter the ball spins that glyph to the front, strikes, and steps one cell right; at the margin it whirls home and drops a line. The paper feeds up when the page fills. With `loop`, the finished sheet is pulled out and typed again. + +Best fit: a letter, a manifesto, or a hero line that should arrive one keystroke at a time. + +Not this: a wordmark (`ascii-logo`), a blinking input caret, or a background behind other copy. + +Deps: none. Props: `text` (`DEFAULT_TEXT`), `speed` (14 chars/s), `humanize` (true), `jitter` (0.3), `columns` (0 = fit), `ticks` (true), `loop` (true), `hold` (2600 ms), `startDelay` (600 ms), `sound` (false), `onDone`. + +```tsx +"use client" +import { Typewriter } from "@/components/ui/typewriter" + +export function Letter() { + return ( +
+ +
+ ) +} +``` + +Pitfalls: `\n` is the only way to force a line break; everything else wraps at words. The font is the project's `font-mono` at the inherited size; set a size with `className="text-lg"`. Ink, rail, and carriage use `currentColor`, so color it with a text class. `sound` is Web Audio and stays silent until the visitor has interacted with the page. Reduced motion prints the whole page at once. Changing `text` starts over; other props apply live. + diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index 463b4c9..5fe706e 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -221,6 +221,21 @@ export function Brand() { `text` longer than 5 characters is truncated. +Letter that types itself: + +```tsx +"use client" +import { Typewriter } from "@/components/ui/typewriter" + +export function Note() { + return ( +
+ +
+ ) +} +``` + ## Anti-patterns - `ShaderGradient` as a color control, or `GooeyColorPicker` as a page background. diff --git a/README.md b/README.md index e07b3ac..356c056 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ npx skills add radiumcoders/23rd.dev --skill 23rd -g | [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections | | [Shader Sky](https://23rd.dev/docs/components/shader-sky) | `@23rd/shader-sky` | Clear blue or rain behind a hero — drifting clouds, optional window-glass film | | [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back | +| [Typewriter](https://23rd.dev/docs/components/typewriter) | `@23rd/typewriter` | A typeball on a rail spins each letter to the front, strikes, and drops a line at the margin | ```tsx import { GooeyColorPicker } from "@/components/ui/gooey-color-picker" diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index 82cda89..83700f7 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -21,6 +21,7 @@ "---Sections---", "folio", "---Components---", - "gooey-color-picker" + "gooey-color-picker", + "typewriter" ] } diff --git a/content/docs/components/typewriter.mdx b/content/docs/components/typewriter.mdx new file mode 100644 index 0000000..ab30085 --- /dev/null +++ b/content/docs/components/typewriter.mdx @@ -0,0 +1,92 @@ +--- +title: Typewriter +description: Text typed onto the page by a typeball on a rail — it spins each letter to the front and strikes, steps right, and at the margin whirls home and drops a line. +--- + +import { TypewriterDemo } from "@/registry/typewriter/typewriter-demo" + +A typewriter, not a blinking caret. A rail spans the frame with a scale of ticks and two margin stops, and a typeball rides it — a sphere wearing the keyboard in three rows, clamped to the rail. For every keystroke the ball spins and tilts until that letter faces the paper, strikes, and steps one cell right. At the end of a line it whirls home along the rail and drops to the next; when the page is full the paper feeds up. Between pages the ball turns slowly on its own. With `loop` on, the finished sheet is pulled out and typed again. Everything is ink in `currentColor`, so it follows the theme. + +## Preview + + + +## Installation + + + +Or install straight from the public GitHub source registry: + + + +## Usage + +Give the parent a size. The typewriter fills it, sets its text in your mono font, and inherits the font size. + + + +```tsx +"use client" + +import { Typewriter } from "@/components/ui/typewriter" + +export function Letter() { + return ( +
+ +
+ ) +} +``` +
+ +```svelte + + +
+ +
+``` +
+
+ +`\n` starts a new line; everything else wraps at words to fit the frame, or at `columns` characters when you set it. Type once and stop, or slow it down and switch the clicks on. + + + + ```tsx + console.log("typed")} /> + + ``` + + + ```svelte + console.log("typed")} /> + + ``` + + + +`sound` synthesizes the key clicks, the margin bell, and the carriage return with Web Audio; browsers only let it play after the visitor has clicked or pressed a key on the page. Screen readers get the whole text at once. `prefers-reduced-motion: reduce` prints the finished page with no animation, and typing pauses while the typewriter is off-screen or the tab is hidden. + +## Props + +| Prop | Type | Default | +| ------------ | ------------ | ---------------------------------------------------- | +| `text` | `string` | a short letter — `\n` breaks a line | +| `speed` | `number` | `14` — characters per second | +| `humanize` | `boolean` | `true` — uneven keys, rests after punctuation | +| `jitter` | `number` | `0.3` — 0–1, per-letter baseline, tilt, and ink | +| `columns` | `number` | `0` — wrap width in characters, `0` fits the frame | +| `ticks` | `boolean` | `true` — scale marks along the rail | +| `loop` | `boolean` | `true` — feed the page out and type it again | +| `hold` | `number` | `2600` — ms on the finished page before looping | +| `startDelay` | `number` | `600` — ms before the first keystroke | +| `sound` | `boolean` | `false` — synthesized clicks, bell, and return | +| `onDone` | `() => void` | called each time the last character lands | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | + +Color it with a text color on the component or its parent (`className="text-sky-700"`); the rail, carriage, and letters all use `currentColor`. The root has `p-6` padding, and the text margin moves in further when the carriage needs room to stay inside the frame. diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index 2503109..2c7f08e 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -13,7 +13,7 @@ Or pull straight from the public GitHub source registry: -Swap `shader-gradient` for any item name — `shader-sky`, `shader-anime-fire`, `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve. +Swap `shader-gradient` for any item name — `shader-sky`, `shader-anime-fire`, `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, `typewriter`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve. ## Use a background or shader @@ -61,4 +61,4 @@ Shaders such as [Shader Gradient](/docs/components/shader-gradient), [Shader Ani | 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) | -| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | +| Components | [Gooey Color Picker](/docs/components/gooey-color-picker), [Typewriter](/docs/components/typewriter) | diff --git a/public/og/docs/components/typewriter/image.png b/public/og/docs/components/typewriter/image.png new file mode 100644 index 0000000000000000000000000000000000000000..27615bb26401e5b6f9d4c4323af7b4eed98a725c GIT binary patch literal 45430 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\nexport const DEFAULT_TEXT =\n \"Dear reader,\\nevery letter lands where the carriage stands, then the carriage steps aside for the next one. At the margin it rings, glides home, and drops a line.\\n\\nYours, 23rd\"\n\n/** Characters per second. */\nexport const DEFAULT_SPEED = 14\n/** Pause on the finished page before it feeds out and starts over, in ms. */\nexport const DEFAULT_HOLD = 2600\n/** Wait before the first keystroke, in ms. */\nexport const DEFAULT_START_DELAY = 600\n/** Per-letter baseline, tilt, and ink wobble (0–1). */\nexport const DEFAULT_JITTER = 0.3\n\n/** Line pitch, in font sizes. Leaves the carriage room under each line. */\nconst LEADING = 2.2\n/** Typeball radius, in font sizes. */\nconst BALL_R = 1.1\n/** Baseline to the top of the typeball, in font sizes. */\nconst BALL_GAP = 0.2\n/** Glyph size on the ball, as a share of its radius. */\nconst BALL_GLYPH = 0.56\n/** Rail thickness, in font sizes. */\nconst RAIL_H = 0.32\n/** Rail clamp width, in font sizes. */\nconst CLAMP_W = 0.26\n/** Gap between the ball and each clamp, in font sizes. */\nconst CLAMP_GAP = 0.14\n/** Bell rings this many cells before the right margin. */\nconst BELL_CELLS = 6\n\nexport type TypewriterOptions = {\n /** What gets typed. `\\n` starts a new line; long lines wrap at words. */\n text?: string\n /** Characters per second. Default `14`. */\n speed?: number\n /** Uneven keystrokes, with longer rests after punctuation. Default `true`. */\n humanize?: boolean\n /** Feed the page out and type it again after `hold`. Default `true`. */\n loop?: boolean\n /** Pause on the finished page before looping, in ms. Default `2600`. */\n hold?: number\n /** Wait before the first keystroke, in ms. Default `600`. */\n startDelay?: number\n /** Wrap width in characters. `0` fits the frame. Default `0`. */\n columns?: number\n /** Scale ticks along the rail. Default `true`. */\n ticks?: boolean\n /** Per-letter baseline, tilt, and ink wobble, 0–1. Default `0.3`. */\n jitter?: number\n /** Synthesized key clicks, bell, and return. Default `false`. */\n sound?: boolean\n /** Called each time the last character lands. */\n onDone?: () => void\n}\n\nexport type TypewriterInstance = {\n setOptions: (options: Partial) => void\n /** Clear the page and type from the first character. */\n restart: () => void\n destroy: () => void\n}\n\nexport type TypewriterStroke =\n | { kind: \"char\"; ch: string; line: number; col: number; src: number }\n | { kind: \"return\"; line: number; from: number; src: number }\n\n/**\n * The keystrokes that type `text` into `columns`-wide lines: characters\n * with their cell, and carriage returns. Words wrap whole unless a word is\n * wider than the line. `src` orders strokes against the source text, so a\n * relayout can resume at the same point.\n */\nexport function layoutTypewriter(\n text: string,\n columns: number\n): TypewriterStroke[] {\n const width = Math.max(1, Math.floor(columns))\n const source = Array.from(text.replace(/\\r\\n?/g, \"\\n\").replace(/\\t/g, \" \"))\n const strokes: TypewriterStroke[] = []\n let line = 0\n let col = 0\n /** Wrap returns wait for the character after them. */\n let pendingWraps: TypewriterStroke[] = []\n\n const feed = (src: number) => {\n const stroke: TypewriterStroke = {\n kind: \"return\",\n line: line + 1,\n from: col,\n src,\n }\n strokes.push(stroke)\n line += 1\n col = 0\n return stroke\n }\n const wrap = () => pendingWraps.push(feed(Infinity))\n const type = (ch: string, src: number) => {\n for (const stroke of pendingWraps) stroke.src = src - 0.5\n pendingWraps = []\n strokes.push({ kind: \"char\", ch, line, col, src })\n col += 1\n }\n\n let i = 0\n while (i < source.length) {\n const ch = source[i]!\n if (ch === \"\\n\") {\n for (const stroke of pendingWraps) stroke.src = i - 0.5\n pendingWraps = []\n feed(i)\n i += 1\n continue\n }\n if (/\\s/.test(ch)) {\n // Spaces past the margin fall off the page.\n if (col < width) type(\" \", i)\n i += 1\n continue\n }\n let end = i\n while (end < source.length && !/\\s/.test(source[end]!)) end += 1\n const length = end - i\n if (col > 0 && col + length > width) wrap()\n for (let k = i; k < end; k++) {\n if (col >= width) wrap()\n type(source[k]!, k)\n }\n i = end\n }\n return strokes\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/** Stable 0–1 noise per integer, so a letter keeps its wobble on relayout. */\nfunction hash(n: number) {\n let x = Math.imul(n + 1, 0x27d4eb2d) ^ 0x165667b1\n x = Math.imul(x ^ (x >>> 15), 0x85ebca6b)\n x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35)\n return ((x ^ (x >>> 16)) >>> 0) / 4294967296\n}\n\nconst SVG_NS = \"http://www.w3.org/2000/svg\"\n\nfunction svgEl(\n tag: K,\n attrs: Record = {}\n) {\n const node = document.createElementNS(SVG_NS, tag)\n for (const [key, value] of Object.entries(attrs)) {\n node.setAttribute(key, String(value))\n }\n return node\n}\n\nfunction div(style: Partial) {\n const node = document.createElement(\"div\")\n Object.assign(node.style, style)\n return node\n}\n\ntype Sound = {\n key: (space: boolean) => void\n bell: () => void\n carriage: (ms: number) => void\n close: () => void\n}\n\n/** Key clicks, the margin bell, and the return ratchet, all synthesized. */\nfunction createSound(): Sound | null {\n const Ctx =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext\n if (!Ctx) return null\n let audio: AudioContext | null = null\n let out: GainNode | null = null\n let noise: AudioBuffer | null = null\n\n const ready = () => {\n if (!audio) {\n audio = new Ctx()\n out = audio.createGain()\n out.gain.value = 0.55\n out.connect(audio.destination)\n noise = audio.createBuffer(\n 1,\n Math.floor(audio.sampleRate * 0.4),\n audio.sampleRate\n )\n const data = noise.getChannelData(0)\n for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1\n }\n if (audio.state === \"suspended\") void audio.resume().catch(() => {})\n return audio.state === \"running\" ? audio : null\n }\n\n const burst = (\n a: AudioContext,\n at: number,\n freq: number,\n q: number,\n peak: number,\n decay: number\n ) => {\n const src = a.createBufferSource()\n src.buffer = noise\n const band = a.createBiquadFilter()\n band.type = \"bandpass\"\n band.frequency.value = freq\n band.Q.value = q\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.002)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + decay)\n src.connect(band).connect(gain).connect(out!)\n src.start(at, Math.random() * 0.2)\n src.stop(at + decay + 0.02)\n }\n\n const thump = (a: AudioContext, at: number, freq: number, peak: number) => {\n const osc = a.createOscillator()\n osc.frequency.setValueAtTime(freq, at)\n osc.frequency.exponentialRampToValueAtTime(freq * 0.5, at + 0.05)\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.003)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.06)\n osc.connect(gain).connect(out!)\n osc.start(at)\n osc.stop(at + 0.08)\n }\n\n return {\n key(space) {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n if (space) {\n burst(a, at, 900, 0.9, 0.22, 0.05)\n thump(a, at, 110, 0.3)\n } else {\n burst(a, at, 2600 + Math.random() * 900, 1.3, 0.34, 0.035)\n thump(a, at, 150 + Math.random() * 30, 0.2)\n }\n },\n bell() {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n for (const [freq, peak] of [\n [2093, 0.16],\n [5776, 0.05],\n ] as const) {\n const osc = a.createOscillator()\n osc.frequency.value = freq\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.004)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + 1.1)\n osc.connect(gain).connect(out!)\n osc.start(at)\n osc.stop(at + 1.2)\n }\n },\n carriage(ms) {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n const seconds = ms / 1000\n // Ratchet clicks as the line feeds, then the glide home.\n for (let i = 0; i < 3; i++) burst(a, at + i * 0.028, 1800, 2, 0.16, 0.02)\n const src = a.createBufferSource()\n src.buffer = noise\n src.loop = true\n const low = a.createBiquadFilter()\n low.type = \"bandpass\"\n low.Q.value = 0.8\n low.frequency.setValueAtTime(700, at)\n low.frequency.linearRampToValueAtTime(1400, at + seconds)\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(0.07, at + seconds * 0.3)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + seconds)\n src.connect(low).connect(gain).connect(out!)\n src.start(at)\n src.stop(at + seconds + 0.02)\n thump(a, at + seconds, 120, 0.28)\n },\n close() {\n void audio?.close().catch(() => {})\n audio = null\n },\n }\n}\n\nconst TAU = Math.PI * 2\n\n/** The ball's glyph rows, top to bottom — the keyboard's three letter rows. */\nconst BALL_FACES = [\"qwertyuiop\", \"asdfghjkl;\", \"zxcvbnm,.?\"]\n/** Latitude of each row on the ball, in radians. */\nconst BALL_ROWS = [0.66, 0, -0.66]\n/** Glyphs sit a little inside the silhouette, so the limb stays clean. */\nconst BALL_INSET = 0.8\n/** Idle spin, in radians per second. */\nconst BALL_DRIFT = 0.55\n\ntype Tween = {\n from: number\n to: number\n start: number\n ms: number\n ease: (t: number) => number\n}\n\nconst easeOut = (t: number) => 1 - (1 - t) ** 3\nconst easeInOut = (t: number) =>\n t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n\n/** Nearest turn of `angle` to `from`, so the ball spins the short way. */\nfunction nearest(from: number, angle: number) {\n return from + ((((angle - from) % TAU) + TAU * 1.5) % TAU) - Math.PI\n}\n\ntype Typeball = {\n el: SVGSVGElement\n /**\n * Size the ball; `radius` and the glyph size are in px. `font` is the\n * page's computed style — mask content doesn't inherit it everywhere.\n */\n layout: (radius: number, glyph: number, font: CSSStyleDeclaration) => void\n /** Spin and tilt until `ch` faces the paper. */\n select: (ch: string, ms: number) => void\n /** Spin whole turns, for the glide home. */\n whirl: (turns: number, ms: number) => void\n /** Turn slowly while nothing is being typed. */\n drift: (on: boolean) => void\n destroy: () => void\n}\n\nlet ballIds = 0\n\n/**\n * The type element: a sphere wearing the keyboard in three rows. It spins\n * about its axis and tilts to bring a glyph to the front — the letters are\n * cut through the ink, so the page shows through them.\n */\nfunction createTypeball(): Typeball {\n const id = `typewriter-ball-${++ballIds}`\n const el = svgEl(\"svg\", { overflow: \"visible\" })\n el.style.position = \"absolute\"\n el.style.overflow = \"visible\"\n const defs = svgEl(\"defs\")\n const shade = svgEl(\"radialGradient\", {\n id: `${id}-shade`,\n cx: 0.36,\n cy: 0.3,\n r: 0.8,\n })\n for (const [offset, alpha] of [\n [0, 0.5],\n [0.5, 0.86],\n [1, 1],\n ] as const) {\n shade.append(\n svgEl(\"stop\", {\n offset,\n \"stop-color\": \"currentColor\",\n \"stop-opacity\": alpha,\n })\n )\n }\n const mask = svgEl(\"mask\", { id: `${id}-cut`, maskUnits: \"userSpaceOnUse\" })\n const paper = svgEl(\"rect\", { fill: \"#fff\" })\n const letters = svgEl(\"g\", {\n fill: \"#000\",\n \"text-anchor\": \"middle\",\n \"dominant-baseline\": \"central\",\n })\n mask.append(paper, letters)\n defs.append(shade, mask)\n const sphere = svgEl(\"circle\", {\n fill: `url(#${id}-shade)`,\n mask: `url(#${id}-cut)`,\n })\n el.append(defs, sphere)\n\n const cols = BALL_FACES[0]!.length\n const slots = BALL_FACES.flatMap((face, row) =>\n Array.from(face, (glyph, col) => {\n const node = svgEl(\"text\")\n node.textContent = glyph\n letters.append(node)\n return { row, col, glyph, node }\n })\n )\n\n let radius = 16\n let lon = 0\n let tilt = 0\n let lonTween: Tween | null = null\n let tiltTween: Tween | null = null\n let drifting = false\n let raf = 0\n let last = 0\n let swapped: (typeof slots)[number] | null = null\n\n const render = () => {\n const r = radius * BALL_INSET\n const sin = Math.sin(tilt)\n const cos = Math.cos(tilt)\n for (const slot of slots) {\n const lat = BALL_ROWS[slot.row]!\n const theta = (slot.col / cols) * TAU - lon\n const x = Math.cos(lat) * Math.sin(theta)\n const y0 = Math.sin(lat)\n const z0 = Math.cos(lat) * Math.cos(theta)\n const y = y0 * cos - z0 * sin\n const z = y0 * sin + z0 * cos\n const seen = clamp((z - 0.12) / 0.4, 0, 1)\n if (seen <= 0) {\n slot.node.setAttribute(\"fill-opacity\", \"0\")\n continue\n }\n const sx = Math.sqrt(Math.max(0.02, 1 - x * x))\n const sy = Math.sqrt(Math.max(0.02, 1 - y * y))\n slot.node.setAttribute(\"fill-opacity\", (seen * 0.86).toFixed(3))\n slot.node.setAttribute(\n \"transform\",\n `translate(${(x * r).toFixed(2)} ${(-y * r).toFixed(2)}) scale(${sx.toFixed(3)} ${sy.toFixed(3)})`\n )\n }\n }\n\n const valueOf = (tween: Tween, now: number) => {\n const t = clamp((now - tween.start) / Math.max(1, tween.ms), 0, 1)\n return tween.from + (tween.to - tween.from) * tween.ease(t)\n }\n\n const frame = (now: number) => {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (lonTween) {\n lon = valueOf(lonTween, now)\n if (now - lonTween.start >= lonTween.ms) lonTween = null\n } else if (drifting) {\n lon += BALL_DRIFT * dt\n }\n if (tiltTween) {\n tilt = valueOf(tiltTween, now)\n if (now - tiltTween.start >= tiltTween.ms) tiltTween = null\n }\n render()\n if (lonTween || tiltTween || drifting) raf = requestAnimationFrame(frame)\n }\n\n const wake = () => {\n if (raf) return\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n\n const tweenTo = (current: number, to: number, ms: number, ease = easeOut) => {\n const now = performance.now()\n return { from: current, to, start: now, ms, ease }\n }\n\n const settle = () => {\n const now = performance.now()\n if (lonTween) lon = valueOf(lonTween, now)\n if (tiltTween) tilt = valueOf(tiltTween, now)\n }\n\n return {\n el,\n layout(nextRadius, glyph, font) {\n radius = nextRadius\n const box = radius + 2\n el.setAttribute(\"width\", String(box * 2))\n el.setAttribute(\"height\", String(box * 2))\n el.setAttribute(\"viewBox\", `${-box} ${-box} ${box * 2} ${box * 2}`)\n sphere.setAttribute(\"r\", String(radius))\n for (const [key, value] of Object.entries({\n x: -box,\n y: -box,\n width: box * 2,\n height: box * 2,\n })) {\n mask.setAttribute(key, String(value))\n paper.setAttribute(key, String(value))\n }\n letters.setAttribute(\"font-size\", glyph.toFixed(2))\n Object.assign(letters.style, {\n fontFamily: font.fontFamily,\n fontStretch: font.fontStretch,\n fontWeight: font.fontWeight,\n fontVariationSettings: font.fontVariationSettings,\n })\n render()\n },\n select(ch, ms) {\n const key = ch.toLowerCase()\n let slot = slots.find((s) => s.glyph === key)\n if (!slot) {\n const code = ch.codePointAt(0) ?? 0\n slot = slots[(code * 7) % slots.length]!\n }\n // Capitals and symbols borrow a slot's face until the next letter.\n if (swapped && swapped !== slot) swapped.node.textContent = swapped.glyph\n swapped = ch === slot.glyph ? null : slot\n slot.node.textContent = ch\n settle()\n const target = nearest(lon, (slot.col / cols) * TAU)\n lonTween = tweenTo(lon, target, ms)\n tiltTween = tweenTo(tilt, BALL_ROWS[slot.row]!, ms)\n wake()\n },\n whirl(turns, ms) {\n settle()\n lonTween = tweenTo(lon, lon + turns * TAU, ms, easeInOut)\n tiltTween = tweenTo(tilt, 0, ms, easeInOut)\n wake()\n },\n drift(on) {\n drifting = on\n if (on) wake()\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\n/**\n * A typewriter on the page. A rail spans the frame and a typeball rides it:\n * for every letter the ball spins that glyph to the front, strikes, and\n * steps one cell right. At the end of a line it whirls home and drops to\n * the next, and when the page is full the paper feeds up. Idle when\n * finished, off-screen, or under `prefers-reduced-motion` (which prints the\n * whole page at once).\n */\nexport function createTypewriter(\n root: HTMLElement,\n initial: TypewriterOptions = {}\n): TypewriterInstance {\n let options: TypewriterOptions = { ...initial }\n const textOf = () => options.text ?? DEFAULT_TEXT\n const speedOf = () => numberOr(options.speed, DEFAULT_SPEED, 1, 120)\n const holdOf = () => numberOr(options.hold, DEFAULT_HOLD, 0, 60000)\n const startOf = () =>\n numberOr(options.startDelay, DEFAULT_START_DELAY, 0, 60000)\n const jitterOf = () => numberOr(options.jitter, DEFAULT_JITTER, 0, 1)\n\n // Screen readers get the whole text once; the machine is decoration.\n const spoken = document.createElement(\"span\")\n Object.assign(spoken.style, {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n margin: \"-1px\",\n padding: \"0\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n })\n\n const stage = div({\n position: \"absolute\",\n inset: \"0\",\n overflow: \"hidden\",\n borderRadius: \"inherit\",\n pointerEvents: \"none\",\n })\n stage.setAttribute(\"aria-hidden\", \"true\")\n const viewport = div({ position: \"absolute\", inset: \"0\" })\n const page = div({\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n right: \"0\",\n whiteSpace: \"pre\",\n willChange: \"transform\",\n })\n const carriage = div({\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n width: \"100%\",\n willChange: \"transform\",\n })\n const rail = svgEl(\"svg\", { overflow: \"visible\" })\n rail.style.position = \"absolute\"\n rail.style.left = \"0\"\n rail.style.overflow = \"visible\"\n const head = div({\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n willChange: \"transform\",\n })\n const clamps = svgEl(\"svg\", { overflow: \"visible\" })\n clamps.style.position = \"absolute\"\n clamps.style.overflow = \"visible\"\n const ball = createTypeball()\n\n head.append(clamps, ball.el)\n carriage.append(rail, head)\n viewport.append(page)\n stage.append(viewport, carriage)\n root.append(spoken, stage)\n\n // Font metrics and frame, refreshed by `measure`.\n const m = {\n fs: 16,\n cw: 9.6,\n lh: 35,\n base: 22,\n width: 0,\n height: 0,\n padT: 0,\n padB: 0,\n margin: 0,\n columns: 1,\n radius: 16,\n /** Baseline to the ball's centre, which is also the rail's. */\n axis: 20,\n /** Cell centre to the outer edge of a rail clamp. */\n half: 24,\n }\n\n let strokes: TypewriterStroke[] = []\n /** Strokes on the page. */\n let pos = 0\n let cursor = { line: 0, col: 0 }\n let scroll = 0\n let lines: HTMLDivElement[] = []\n let phase: \"waiting\" | \"typing\" | \"done\" | \"feeding\" = \"waiting\"\n let rang = false\n let destroyed = false\n let reduce = false\n let onScreen = true\n let sound: Sound | null = null\n\n // One pending step, paused while the machine can't be seen.\n let pending: (() => void) | null = null\n let timer = 0\n let remaining = 0\n let dueAt = 0\n const active = () =>\n !destroyed && onScreen && !reduce && document.visibilityState !== \"hidden\"\n const arm = () => {\n dueAt = performance.now() + remaining\n timer = window.setTimeout(() => {\n timer = 0\n const run = pending\n pending = null\n run?.()\n }, remaining)\n }\n const schedule = (run: () => void, ms: number) => {\n window.clearTimeout(timer)\n timer = 0\n pending = run\n remaining = Math.max(0, ms)\n if (active()) arm()\n }\n const cancel = () => {\n window.clearTimeout(timer)\n timer = 0\n pending = null\n }\n /** The ball turns idly between pages, but only while anyone can see it. */\n const idle = () =>\n ball.drift((phase === \"waiting\" || phase === \"done\") && active())\n const sync = () => {\n if (active()) {\n if (pending && !timer) arm()\n } else if (timer) {\n window.clearTimeout(timer)\n timer = 0\n remaining = Math.max(0, dueAt - performance.now())\n }\n idle()\n }\n const setPhase = (next: typeof phase) => {\n phase = next\n idle()\n }\n\n const soundOn = () => {\n if (!options.sound) return null\n sound ??= createSound()\n return sound\n }\n\n const cellX = (col: number) => m.margin + col * m.cw\n const lineTop = (line: number) => m.padT + line * m.lh\n /** How far the paper must feed up to keep the carriage on `line` in frame. */\n const scrollFor = (line: number) => {\n const floor = m.height - Math.min(m.padB, m.fs * 0.6)\n return Math.max(0, lineTop(line) + m.base + m.axis + m.radius - floor)\n }\n\n const setTransition = (\n el: HTMLElement,\n ms: number,\n ease: string,\n delay = 0\n ) => {\n el.style.transition =\n ms > 0 ? `transform ${ms}ms ${ease} ${Math.round(delay)}ms` : \"none\"\n }\n const placeHead = (col: number, ms = 0, ease = \"linear\", delay = 0) => {\n setTransition(head, ms, ease, delay)\n head.style.transform = `translate3d(${cellX(col)}px,0,0)`\n }\n const placeCarriage = (line: number, ms = 0, ease = \"linear\") => {\n setTransition(carriage, ms, ease)\n const y = Math.round(lineTop(line) - scroll + m.base)\n carriage.style.transform = `translate3d(0,${y}px,0)`\n }\n const placePage = (ms = 0, ease = \"linear\") => {\n page.style.transition =\n ms > 0 ? `transform ${ms}ms ${ease}, opacity ${ms}ms ${ease}` : \"none\"\n page.style.transform = `translate3d(0,${-scroll}px,0)`\n viewport.style.maskImage = viewport.style.webkitMaskImage =\n scroll > 0\n ? `linear-gradient(to bottom, transparent 0, #000 ${Math.max(m.padT, m.lh * 0.8)}px)`\n : \"\"\n }\n\n const lineEl = (index: number) => {\n while (lines.length <= index) {\n const el = div({\n position: \"absolute\",\n left: \"0\",\n right: \"0\",\n top: `${lineTop(lines.length)}px`,\n height: `${m.lh}px`,\n })\n page.append(el)\n lines.push(el)\n }\n return lines[index]!\n }\n\n const letter = (stroke: Extract) => {\n const jitter = jitterOf()\n const n = Math.floor(stroke.src) * 4\n const el = document.createElement(\"span\")\n el.textContent = stroke.ch\n Object.assign(el.style, {\n position: \"absolute\",\n top: \"0\",\n left: `${cellX(stroke.col)}px`,\n width: `${m.cw}px`,\n height: `${m.lh}px`,\n lineHeight: `${m.lh}px`,\n textAlign: \"center\",\n })\n if (jitter > 0) {\n const dx = (hash(n) - 0.5) * jitter * 0.07 * m.fs\n const dy = (hash(n + 1) - 0.5) * jitter * 0.1 * m.fs\n const tilt = (hash(n + 2) - 0.5) * jitter * 3.2\n el.style.transform = `translate(${dx.toFixed(2)}px,${dy.toFixed(2)}px) rotate(${tilt.toFixed(2)}deg)`\n el.style.opacity = (1 - hash(n + 3) * jitter * 0.42).toFixed(3)\n }\n lineEl(stroke.line).append(el)\n return el\n }\n\n const after = (stroke: TypewriterStroke | undefined) => {\n if (!stroke) return { line: 0, col: 0 }\n return stroke.kind === \"char\"\n ? { line: stroke.line, col: stroke.col + 1 }\n : { line: stroke.line, col: 0 }\n }\n\n /** Paint the first `pos` strokes and park the carriage, without motion. */\n const paint = () => {\n page.replaceChildren()\n lines = []\n for (let i = 0; i < pos; i++) {\n const stroke = strokes[i]!\n if (stroke.kind === \"char\" && stroke.ch !== \" \") letter(stroke)\n }\n cursor = after(strokes[pos - 1])\n scroll = scrollFor(cursor.line)\n placePage()\n placeCarriage(cursor.line)\n placeHead(cursor.col)\n rang = cursor.col >= m.columns - BELL_CELLS\n }\n\n const drawRail = () => {\n const { width, fs, cw, columns } = m\n const h = Math.max(3, Math.round(RAIL_H * fs))\n rail.setAttribute(\"width\", String(width))\n rail.setAttribute(\"height\", String(h + 1))\n rail.setAttribute(\"viewBox\", `0 0 ${width} ${h + 1}`)\n rail.style.top = `${Math.round(m.axis - h / 2)}px`\n rail.replaceChildren()\n const g = svgEl(\"g\", { fill: \"none\", stroke: \"currentColor\" })\n g.append(\n svgEl(\"path\", {\n d: `M0 0.5H${width}M0 ${h + 0.5}H${width}`,\n \"stroke-opacity\": 0.3,\n \"stroke-width\": 1,\n })\n )\n if (options.ticks ?? true) {\n let minor = \"\"\n let major = \"\"\n for (let c = 0; c <= columns; c++) {\n const x = Math.round(m.margin + c * cw + cw / 2) + 0.5\n if (c % 5 === 0) major += `M${x} 1V${c % 10 === 0 ? h : h * 0.62}`\n else minor += `M${x} 1V${h * 0.36}`\n }\n g.append(\n svgEl(\"path\", { d: minor, \"stroke-opacity\": 0.22, \"stroke-width\": 1 }),\n svgEl(\"path\", { d: major, \"stroke-opacity\": 0.42, \"stroke-width\": 1 })\n )\n }\n // Margin stops: where the carriage returns to, and where the bell rings.\n const stopW = Math.max(2, Math.round(fs * 0.14))\n const stopH = h + Math.round(fs * 0.32)\n for (const x of [m.margin, m.margin + columns * cw]) {\n g.append(\n svgEl(\"rect\", {\n x: Math.round(x - stopW / 2),\n y: Math.round((h - stopH) / 2) + 0.5,\n width: stopW,\n height: stopH,\n rx: stopW / 2,\n fill: \"currentColor\",\n \"fill-opacity\": 0.55,\n stroke: \"none\",\n })\n )\n }\n rail.append(g)\n }\n\n /** The ball, and the two clamps that hold it on the rail. */\n const drawHead = () => {\n const { fs, cw, radius, axis, half } = m\n const box = radius + 2\n ball.layout(radius, radius * BALL_GLYPH, getComputedStyle(root))\n ball.el.style.left = `${cw / 2 - box}px`\n ball.el.style.top = `${axis - box}px`\n\n const railH = Math.max(3, Math.round(RAIL_H * fs))\n const w = Math.max(3, Math.round(CLAMP_W * fs))\n const h = railH + Math.round(fs * 0.42)\n const gap = Math.round(CLAMP_GAP * fs)\n clamps.setAttribute(\"width\", String(half * 2))\n clamps.setAttribute(\"height\", String(h))\n clamps.setAttribute(\"viewBox\", `${-half} ${-h / 2} ${half * 2} ${h}`)\n clamps.style.left = `${cw / 2 - half}px`\n clamps.style.top = `${axis - h / 2}px`\n clamps.replaceChildren()\n for (const side of [-1, 1]) {\n const inner = radius + gap\n const x = side < 0 ? -inner - w : inner\n clamps.append(\n // The collar that grips the rail…\n svgEl(\"rect\", {\n x,\n y: -h / 2,\n width: w,\n height: h,\n rx: Math.min(w / 2, fs * 0.12),\n fill: \"currentColor\",\n }),\n // …and the axle stub it holds the ball by.\n svgEl(\"rect\", {\n x: side < 0 ? -inner : radius - 1,\n y: -Math.max(1, railH * 0.25),\n width: gap + 1,\n height: Math.max(2, railH * 0.5),\n fill: \"currentColor\",\n })\n )\n }\n }\n\n const measure = () => {\n const cs = getComputedStyle(root)\n const fs = parseFloat(cs.fontSize) || 16\n const probe = document.createElement(\"span\")\n probe.textContent = \"0000000000\"\n Object.assign(probe.style, {\n position: \"absolute\",\n visibility: \"hidden\",\n whiteSpace: \"pre\",\n })\n const marker = document.createElement(\"span\")\n Object.assign(marker.style, {\n display: \"inline-block\",\n width: \"0\",\n height: \"0\",\n verticalAlign: \"baseline\",\n })\n const lh = Math.round(fs * LEADING)\n const row = div({\n position: \"absolute\",\n visibility: \"hidden\",\n lineHeight: `${lh}px`,\n height: `${lh}px`,\n })\n row.append(\"x\", marker)\n stage.append(probe, row)\n const cw = probe.getBoundingClientRect().width / 10 || fs * 0.6\n const base = marker.offsetTop\n probe.remove()\n row.remove()\n\n const width = root.clientWidth\n const height = root.clientHeight\n const padL = parseFloat(cs.paddingLeft) || 0\n const padR = parseFloat(cs.paddingRight) || 0\n const radius = Math.round(fs * BALL_R)\n const half = Math.ceil(\n radius + Math.round(CLAMP_GAP * fs) + Math.round(CLAMP_W * fs)\n )\n const reach = half - cw / 2\n const edge = fs * 0.5\n // The carriage never hangs off the frame, even at the margins.\n const margin = Math.max(padL, Math.ceil(reach + edge))\n const fit = Math.floor(\n Math.min(width - padR - margin, width - edge - reach - cw - margin) / cw\n )\n const wanted = Math.floor(numberOr(options.columns, 0, 0, 1000))\n const columns = Math.max(1, wanted > 0 ? Math.min(wanted, fit) : fit)\n\n const changed =\n fs !== m.fs ||\n cw !== m.cw ||\n lh !== m.lh ||\n columns !== m.columns ||\n width !== m.width\n Object.assign(m, {\n fs,\n cw,\n lh,\n base,\n width,\n height,\n padT: parseFloat(cs.paddingTop) || 0,\n padB: parseFloat(cs.paddingBottom) || 0,\n margin,\n columns,\n radius,\n axis: Math.round(BALL_GAP * fs + radius),\n half,\n })\n return changed\n }\n\n /** Lay the text out again for the current frame, keeping the place. */\n const relayout = () => {\n const at = strokes[pos - 1]?.src ?? -1\n strokes = layoutTypewriter(textOf(), m.columns)\n pos = phase === \"done\" ? strokes.length : 0\n if (phase !== \"done\") {\n while (pos < strokes.length && strokes[pos]!.src <= at) pos += 1\n }\n drawRail()\n drawHead()\n paint()\n }\n\n const pace = (ch: string) => {\n const base = 1000 / speedOf()\n if (!(options.humanize ?? true)) return base\n let factor = 0.55 + Math.random() * 0.9\n if (ch === \" \") factor *= 1.15\n else if (/[,;:]/.test(ch)) factor *= 2.4\n else if (/[.!?]/.test(ch)) factor *= 3.4\n if (Math.random() < 0.04) factor *= 2.6\n return base * factor\n }\n\n /** Spin the glyph round, strike, then step the carriage a cell right. */\n const strike = (stroke: Extract) => {\n const base = 1000 / speedOf()\n const spin = stroke.ch === \" \" ? 0 : clamp(base * 0.4, 16, 70)\n const step = Math.min(90, base * 0.7)\n if (stroke.ch !== \" \") {\n ball.select(stroke.ch, spin)\n letter(stroke).animate([{ opacity: 0 }, {}], {\n duration: Math.min(70, step),\n delay: spin,\n easing: \"ease-out\",\n fill: \"backwards\",\n })\n ball.el.animate(\n [\n { transform: \"translateY(0)\" },\n { transform: `translateY(${(-m.fs * 0.22).toFixed(2)}px)` },\n { transform: \"translateY(0)\" },\n ],\n {\n duration: Math.min(110, base * 0.9),\n delay: spin * 0.55,\n easing: \"cubic-bezier(.3,.7,.4,1)\",\n }\n )\n }\n cursor = { line: stroke.line, col: stroke.col + 1 }\n placeHead(cursor.col, step, \"cubic-bezier(.2,.8,.2,1)\", spin)\n const audio = soundOn()\n audio?.key(stroke.ch === \" \")\n if (\n !rang &&\n cursor.col >= m.columns - BELL_CELLS &&\n m.columns > BELL_CELLS * 2\n ) {\n rang = true\n audio?.bell()\n }\n }\n\n /** Whirl home along the rail and drop a line. */\n const carriageReturn = (\n stroke: Extract\n ) => {\n const glide = clamp(200 + stroke.from * 7, 240, 620)\n const drop = 260\n cursor = { line: stroke.line, col: 0 }\n rang = false\n const next = Math.max(scroll, scrollFor(cursor.line))\n if (next !== scroll) {\n scroll = next\n placePage(drop, \"cubic-bezier(.3,.7,.2,1)\")\n }\n placeCarriage(cursor.line, drop, \"cubic-bezier(.3,.7,.2,1)\")\n placeHead(0, glide, \"cubic-bezier(.65,0,.25,1)\")\n if (stroke.from > 0) {\n ball.whirl(-(0.5 + stroke.from / Math.max(1, m.columns)), glide)\n }\n soundOn()?.carriage(glide)\n return glide\n }\n\n const finish = () => {\n setPhase(\"done\")\n options.onDone?.()\n if (options.loop ?? true) schedule(feedOut, holdOf())\n }\n\n const step = () => {\n if (pos >= strokes.length) {\n finish()\n return\n }\n if (phase !== \"typing\") setPhase(\"typing\")\n const stroke = strokes[pos++]!\n if (stroke.kind === \"char\") {\n strike(stroke)\n schedule(step, pace(stroke.ch))\n } else {\n const glide = carriageReturn(stroke)\n schedule(step, glide + pace(\" \") * 0.8)\n }\n }\n\n /** Pull the finished sheet up and out, bring the carriage home, go again. */\n const feedOut = () => {\n setPhase(\"feeding\")\n const ms = 820\n const pull = \"cubic-bezier(.55,0,.75,.2)\"\n const lift = lineTop(cursor.line + 1) + m.lh\n page.style.transition = `transform ${ms}ms ${pull}, opacity ${ms}ms ease-in`\n page.style.transform = `translate3d(0,${-scroll - lift}px,0)`\n page.style.opacity = \"0\"\n scroll = 0\n // Same curve as the sheet, which travels further, so the carriage rises\n // behind the last line instead of through it.\n placeCarriage(0, ms, pull)\n placeHead(0, ms, \"cubic-bezier(.65,0,.35,1)\")\n if (cursor.col > 0) ball.whirl(-1, ms)\n schedule(() => {\n pos = 0\n page.style.opacity = \"\"\n paint()\n setPhase(\"waiting\")\n schedule(step, startOf() * 0.6)\n }, ms + 60)\n }\n\n const start = () => {\n cancel()\n page.style.opacity = \"\"\n strokes = layoutTypewriter(textOf(), m.columns)\n spoken.textContent = textOf()\n if (reduce) {\n pos = strokes.length\n setPhase(\"done\")\n paint()\n return\n }\n pos = 0\n setPhase(\"waiting\")\n paint()\n schedule(step, startOf())\n }\n\n measure()\n drawRail()\n drawHead()\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n reduce = mqReduce.matches\n const onReduce = () => {\n reduce = mqReduce.matches\n start()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n start()\n\n const onResize = () => {\n if (measure()) relayout()\n else {\n scroll = Math.max(scroll, scrollFor(cursor.line))\n placePage()\n placeCarriage(cursor.line)\n }\n }\n const ro = new ResizeObserver(onResize)\n ro.observe(root)\n // Webfonts can land after mount and change the cell width.\n void document.fonts?.ready.then(() => {\n if (!destroyed) onResize()\n })\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n sync()\n })\n io.observe(root)\n const onVisibility = () => sync()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (textOf() !== (prev.text ?? DEFAULT_TEXT)) {\n start()\n return\n }\n if (options.columns !== prev.columns) {\n measure()\n relayout()\n } else if (options.jitter !== prev.jitter) {\n paint()\n }\n if (options.ticks !== prev.ticks) drawRail()\n const loop = options.loop ?? true\n if (loop !== (prev.loop ?? true) && phase === \"done\") {\n if (loop) schedule(feedOut, holdOf())\n else cancel()\n }\n },\n restart: start,\n destroy() {\n destroyed = true\n cancel()\n ball.destroy()\n ro.disconnect()\n io.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n document.removeEventListener(\"visibilitychange\", onVisibility)\n sound?.close()\n spoken.remove()\n stage.remove()\n },\n }\n}\n\n\n\n\n\n\n", + "type": "registry:file", + "target": "src/lib/components/ui/typewriter.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/typewriter.json b/public/r/typewriter.json new file mode 100644 index 0000000..152c1ce --- /dev/null +++ b/public/r/typewriter.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "typewriter", + "title": "Typewriter", + "description": "Text typed onto the page by a typeball riding a rail — it spins each letter to the front, strikes, steps right, and at the margin whirls home and drops a line.", + "files": [ + { + "path": "registry/typewriter/typewriter.tsx", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_TEXT =\n \"Dear reader,\\nevery letter lands where the carriage stands, then the carriage steps aside for the next one. At the margin it rings, glides home, and drops a line.\\n\\nYours, 23rd\"\n\n/** Characters per second. */\nexport const DEFAULT_SPEED = 14\n/** Pause on the finished page before it feeds out and starts over, in ms. */\nexport const DEFAULT_HOLD = 2600\n/** Wait before the first keystroke, in ms. */\nexport const DEFAULT_START_DELAY = 600\n/** Per-letter baseline, tilt, and ink wobble (0–1). */\nexport const DEFAULT_JITTER = 0.3\n\n/** Line pitch, in font sizes. Leaves the carriage room under each line. */\nconst LEADING = 2.2\n/** Typeball radius, in font sizes. */\nconst BALL_R = 1.1\n/** Baseline to the top of the typeball, in font sizes. */\nconst BALL_GAP = 0.2\n/** Glyph size on the ball, as a share of its radius. */\nconst BALL_GLYPH = 0.56\n/** Rail thickness, in font sizes. */\nconst RAIL_H = 0.32\n/** Rail clamp width, in font sizes. */\nconst CLAMP_W = 0.26\n/** Gap between the ball and each clamp, in font sizes. */\nconst CLAMP_GAP = 0.14\n/** Bell rings this many cells before the right margin. */\nconst BELL_CELLS = 6\n\nexport type TypewriterOptions = {\n /** What gets typed. `\\n` starts a new line; long lines wrap at words. */\n text?: string\n /** Characters per second. Default `14`. */\n speed?: number\n /** Uneven keystrokes, with longer rests after punctuation. Default `true`. */\n humanize?: boolean\n /** Feed the page out and type it again after `hold`. Default `true`. */\n loop?: boolean\n /** Pause on the finished page before looping, in ms. Default `2600`. */\n hold?: number\n /** Wait before the first keystroke, in ms. Default `600`. */\n startDelay?: number\n /** Wrap width in characters. `0` fits the frame. Default `0`. */\n columns?: number\n /** Scale ticks along the rail. Default `true`. */\n ticks?: boolean\n /** Per-letter baseline, tilt, and ink wobble, 0–1. Default `0.3`. */\n jitter?: number\n /** Synthesized key clicks, bell, and return. Default `false`. */\n sound?: boolean\n /** Called each time the last character lands. */\n onDone?: () => void\n}\n\nexport type TypewriterInstance = {\n setOptions: (options: Partial) => void\n /** Clear the page and type from the first character. */\n restart: () => void\n destroy: () => void\n}\n\nexport type TypewriterStroke =\n | { kind: \"char\"; ch: string; line: number; col: number; src: number }\n | { kind: \"return\"; line: number; from: number; src: number }\n\n/**\n * The keystrokes that type `text` into `columns`-wide lines: characters\n * with their cell, and carriage returns. Words wrap whole unless a word is\n * wider than the line. `src` orders strokes against the source text, so a\n * relayout can resume at the same point.\n */\nexport function layoutTypewriter(\n text: string,\n columns: number\n): TypewriterStroke[] {\n const width = Math.max(1, Math.floor(columns))\n const source = Array.from(text.replace(/\\r\\n?/g, \"\\n\").replace(/\\t/g, \" \"))\n const strokes: TypewriterStroke[] = []\n let line = 0\n let col = 0\n /** Wrap returns wait for the character after them. */\n let pendingWraps: TypewriterStroke[] = []\n\n const feed = (src: number) => {\n const stroke: TypewriterStroke = {\n kind: \"return\",\n line: line + 1,\n from: col,\n src,\n }\n strokes.push(stroke)\n line += 1\n col = 0\n return stroke\n }\n const wrap = () => pendingWraps.push(feed(Infinity))\n const type = (ch: string, src: number) => {\n for (const stroke of pendingWraps) stroke.src = src - 0.5\n pendingWraps = []\n strokes.push({ kind: \"char\", ch, line, col, src })\n col += 1\n }\n\n let i = 0\n while (i < source.length) {\n const ch = source[i]!\n if (ch === \"\\n\") {\n for (const stroke of pendingWraps) stroke.src = i - 0.5\n pendingWraps = []\n feed(i)\n i += 1\n continue\n }\n if (/\\s/.test(ch)) {\n // Spaces past the margin fall off the page.\n if (col < width) type(\" \", i)\n i += 1\n continue\n }\n let end = i\n while (end < source.length && !/\\s/.test(source[end]!)) end += 1\n const length = end - i\n if (col > 0 && col + length > width) wrap()\n for (let k = i; k < end; k++) {\n if (col >= width) wrap()\n type(source[k]!, k)\n }\n i = end\n }\n return strokes\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/** Stable 0–1 noise per integer, so a letter keeps its wobble on relayout. */\nfunction hash(n: number) {\n let x = Math.imul(n + 1, 0x27d4eb2d) ^ 0x165667b1\n x = Math.imul(x ^ (x >>> 15), 0x85ebca6b)\n x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35)\n return ((x ^ (x >>> 16)) >>> 0) / 4294967296\n}\n\nconst SVG_NS = \"http://www.w3.org/2000/svg\"\n\nfunction svgEl(\n tag: K,\n attrs: Record = {}\n) {\n const node = document.createElementNS(SVG_NS, tag)\n for (const [key, value] of Object.entries(attrs)) {\n node.setAttribute(key, String(value))\n }\n return node\n}\n\nfunction div(style: Partial) {\n const node = document.createElement(\"div\")\n Object.assign(node.style, style)\n return node\n}\n\ntype Sound = {\n key: (space: boolean) => void\n bell: () => void\n carriage: (ms: number) => void\n close: () => void\n}\n\n/** Key clicks, the margin bell, and the return ratchet, all synthesized. */\nfunction createSound(): Sound | null {\n const Ctx =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext\n if (!Ctx) return null\n let audio: AudioContext | null = null\n let out: GainNode | null = null\n let noise: AudioBuffer | null = null\n\n const ready = () => {\n if (!audio) {\n audio = new Ctx()\n out = audio.createGain()\n out.gain.value = 0.55\n out.connect(audio.destination)\n noise = audio.createBuffer(\n 1,\n Math.floor(audio.sampleRate * 0.4),\n audio.sampleRate\n )\n const data = noise.getChannelData(0)\n for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1\n }\n if (audio.state === \"suspended\") void audio.resume().catch(() => {})\n return audio.state === \"running\" ? audio : null\n }\n\n const burst = (\n a: AudioContext,\n at: number,\n freq: number,\n q: number,\n peak: number,\n decay: number\n ) => {\n const src = a.createBufferSource()\n src.buffer = noise\n const band = a.createBiquadFilter()\n band.type = \"bandpass\"\n band.frequency.value = freq\n band.Q.value = q\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.002)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + decay)\n src.connect(band).connect(gain).connect(out!)\n src.start(at, Math.random() * 0.2)\n src.stop(at + decay + 0.02)\n }\n\n const thump = (a: AudioContext, at: number, freq: number, peak: number) => {\n const osc = a.createOscillator()\n osc.frequency.setValueAtTime(freq, at)\n osc.frequency.exponentialRampToValueAtTime(freq * 0.5, at + 0.05)\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.003)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.06)\n osc.connect(gain).connect(out!)\n osc.start(at)\n osc.stop(at + 0.08)\n }\n\n return {\n key(space) {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n if (space) {\n burst(a, at, 900, 0.9, 0.22, 0.05)\n thump(a, at, 110, 0.3)\n } else {\n burst(a, at, 2600 + Math.random() * 900, 1.3, 0.34, 0.035)\n thump(a, at, 150 + Math.random() * 30, 0.2)\n }\n },\n bell() {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n for (const [freq, peak] of [\n [2093, 0.16],\n [5776, 0.05],\n ] as const) {\n const osc = a.createOscillator()\n osc.frequency.value = freq\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.004)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + 1.1)\n osc.connect(gain).connect(out!)\n osc.start(at)\n osc.stop(at + 1.2)\n }\n },\n carriage(ms) {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n const seconds = ms / 1000\n // Ratchet clicks as the line feeds, then the glide home.\n for (let i = 0; i < 3; i++) burst(a, at + i * 0.028, 1800, 2, 0.16, 0.02)\n const src = a.createBufferSource()\n src.buffer = noise\n src.loop = true\n const low = a.createBiquadFilter()\n low.type = \"bandpass\"\n low.Q.value = 0.8\n low.frequency.setValueAtTime(700, at)\n low.frequency.linearRampToValueAtTime(1400, at + seconds)\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(0.07, at + seconds * 0.3)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + seconds)\n src.connect(low).connect(gain).connect(out!)\n src.start(at)\n src.stop(at + seconds + 0.02)\n thump(a, at + seconds, 120, 0.28)\n },\n close() {\n void audio?.close().catch(() => {})\n audio = null\n },\n }\n}\n\nconst TAU = Math.PI * 2\n\n/** The ball's glyph rows, top to bottom — the keyboard's three letter rows. */\nconst BALL_FACES = [\"qwertyuiop\", \"asdfghjkl;\", \"zxcvbnm,.?\"]\n/** Latitude of each row on the ball, in radians. */\nconst BALL_ROWS = [0.66, 0, -0.66]\n/** Glyphs sit a little inside the silhouette, so the limb stays clean. */\nconst BALL_INSET = 0.8\n/** Idle spin, in radians per second. */\nconst BALL_DRIFT = 0.55\n\ntype Tween = {\n from: number\n to: number\n start: number\n ms: number\n ease: (t: number) => number\n}\n\nconst easeOut = (t: number) => 1 - (1 - t) ** 3\nconst easeInOut = (t: number) =>\n t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n\n/** Nearest turn of `angle` to `from`, so the ball spins the short way. */\nfunction nearest(from: number, angle: number) {\n return from + ((((angle - from) % TAU) + TAU * 1.5) % TAU) - Math.PI\n}\n\ntype Typeball = {\n el: SVGSVGElement\n /**\n * Size the ball; `radius` and the glyph size are in px. `font` is the\n * page's computed style — mask content doesn't inherit it everywhere.\n */\n layout: (radius: number, glyph: number, font: CSSStyleDeclaration) => void\n /** Spin and tilt until `ch` faces the paper. */\n select: (ch: string, ms: number) => void\n /** Spin whole turns, for the glide home. */\n whirl: (turns: number, ms: number) => void\n /** Turn slowly while nothing is being typed. */\n drift: (on: boolean) => void\n destroy: () => void\n}\n\nlet ballIds = 0\n\n/**\n * The type element: a sphere wearing the keyboard in three rows. It spins\n * about its axis and tilts to bring a glyph to the front — the letters are\n * cut through the ink, so the page shows through them.\n */\nfunction createTypeball(): Typeball {\n const id = `typewriter-ball-${++ballIds}`\n const el = svgEl(\"svg\", { overflow: \"visible\" })\n el.style.position = \"absolute\"\n el.style.overflow = \"visible\"\n const defs = svgEl(\"defs\")\n const shade = svgEl(\"radialGradient\", {\n id: `${id}-shade`,\n cx: 0.36,\n cy: 0.3,\n r: 0.8,\n })\n for (const [offset, alpha] of [\n [0, 0.5],\n [0.5, 0.86],\n [1, 1],\n ] as const) {\n shade.append(\n svgEl(\"stop\", {\n offset,\n \"stop-color\": \"currentColor\",\n \"stop-opacity\": alpha,\n })\n )\n }\n const mask = svgEl(\"mask\", { id: `${id}-cut`, maskUnits: \"userSpaceOnUse\" })\n const paper = svgEl(\"rect\", { fill: \"#fff\" })\n const letters = svgEl(\"g\", {\n fill: \"#000\",\n \"text-anchor\": \"middle\",\n \"dominant-baseline\": \"central\",\n })\n mask.append(paper, letters)\n defs.append(shade, mask)\n const sphere = svgEl(\"circle\", {\n fill: `url(#${id}-shade)`,\n mask: `url(#${id}-cut)`,\n })\n el.append(defs, sphere)\n\n const cols = BALL_FACES[0]!.length\n const slots = BALL_FACES.flatMap((face, row) =>\n Array.from(face, (glyph, col) => {\n const node = svgEl(\"text\")\n node.textContent = glyph\n letters.append(node)\n return { row, col, glyph, node }\n })\n )\n\n let radius = 16\n let lon = 0\n let tilt = 0\n let lonTween: Tween | null = null\n let tiltTween: Tween | null = null\n let drifting = false\n let raf = 0\n let last = 0\n let swapped: (typeof slots)[number] | null = null\n\n const render = () => {\n const r = radius * BALL_INSET\n const sin = Math.sin(tilt)\n const cos = Math.cos(tilt)\n for (const slot of slots) {\n const lat = BALL_ROWS[slot.row]!\n const theta = (slot.col / cols) * TAU - lon\n const x = Math.cos(lat) * Math.sin(theta)\n const y0 = Math.sin(lat)\n const z0 = Math.cos(lat) * Math.cos(theta)\n const y = y0 * cos - z0 * sin\n const z = y0 * sin + z0 * cos\n const seen = clamp((z - 0.12) / 0.4, 0, 1)\n if (seen <= 0) {\n slot.node.setAttribute(\"fill-opacity\", \"0\")\n continue\n }\n const sx = Math.sqrt(Math.max(0.02, 1 - x * x))\n const sy = Math.sqrt(Math.max(0.02, 1 - y * y))\n slot.node.setAttribute(\"fill-opacity\", (seen * 0.86).toFixed(3))\n slot.node.setAttribute(\n \"transform\",\n `translate(${(x * r).toFixed(2)} ${(-y * r).toFixed(2)}) scale(${sx.toFixed(3)} ${sy.toFixed(3)})`\n )\n }\n }\n\n const valueOf = (tween: Tween, now: number) => {\n const t = clamp((now - tween.start) / Math.max(1, tween.ms), 0, 1)\n return tween.from + (tween.to - tween.from) * tween.ease(t)\n }\n\n const frame = (now: number) => {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (lonTween) {\n lon = valueOf(lonTween, now)\n if (now - lonTween.start >= lonTween.ms) lonTween = null\n } else if (drifting) {\n lon += BALL_DRIFT * dt\n }\n if (tiltTween) {\n tilt = valueOf(tiltTween, now)\n if (now - tiltTween.start >= tiltTween.ms) tiltTween = null\n }\n render()\n if (lonTween || tiltTween || drifting) raf = requestAnimationFrame(frame)\n }\n\n const wake = () => {\n if (raf) return\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n\n const tweenTo = (current: number, to: number, ms: number, ease = easeOut) => {\n const now = performance.now()\n return { from: current, to, start: now, ms, ease }\n }\n\n const settle = () => {\n const now = performance.now()\n if (lonTween) lon = valueOf(lonTween, now)\n if (tiltTween) tilt = valueOf(tiltTween, now)\n }\n\n return {\n el,\n layout(nextRadius, glyph, font) {\n radius = nextRadius\n const box = radius + 2\n el.setAttribute(\"width\", String(box * 2))\n el.setAttribute(\"height\", String(box * 2))\n el.setAttribute(\"viewBox\", `${-box} ${-box} ${box * 2} ${box * 2}`)\n sphere.setAttribute(\"r\", String(radius))\n for (const [key, value] of Object.entries({\n x: -box,\n y: -box,\n width: box * 2,\n height: box * 2,\n })) {\n mask.setAttribute(key, String(value))\n paper.setAttribute(key, String(value))\n }\n letters.setAttribute(\"font-size\", glyph.toFixed(2))\n Object.assign(letters.style, {\n fontFamily: font.fontFamily,\n fontStretch: font.fontStretch,\n fontWeight: font.fontWeight,\n fontVariationSettings: font.fontVariationSettings,\n })\n render()\n },\n select(ch, ms) {\n const key = ch.toLowerCase()\n let slot = slots.find((s) => s.glyph === key)\n if (!slot) {\n const code = ch.codePointAt(0) ?? 0\n slot = slots[(code * 7) % slots.length]!\n }\n // Capitals and symbols borrow a slot's face until the next letter.\n if (swapped && swapped !== slot) swapped.node.textContent = swapped.glyph\n swapped = ch === slot.glyph ? null : slot\n slot.node.textContent = ch\n settle()\n const target = nearest(lon, (slot.col / cols) * TAU)\n lonTween = tweenTo(lon, target, ms)\n tiltTween = tweenTo(tilt, BALL_ROWS[slot.row]!, ms)\n wake()\n },\n whirl(turns, ms) {\n settle()\n lonTween = tweenTo(lon, lon + turns * TAU, ms, easeInOut)\n tiltTween = tweenTo(tilt, 0, ms, easeInOut)\n wake()\n },\n drift(on) {\n drifting = on\n if (on) wake()\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\n/**\n * A typewriter on the page. A rail spans the frame and a typeball rides it:\n * for every letter the ball spins that glyph to the front, strikes, and\n * steps one cell right. At the end of a line it whirls home and drops to\n * the next, and when the page is full the paper feeds up. Idle when\n * finished, off-screen, or under `prefers-reduced-motion` (which prints the\n * whole page at once).\n */\nexport function createTypewriter(\n root: HTMLElement,\n initial: TypewriterOptions = {}\n): TypewriterInstance {\n let options: TypewriterOptions = { ...initial }\n const textOf = () => options.text ?? DEFAULT_TEXT\n const speedOf = () => numberOr(options.speed, DEFAULT_SPEED, 1, 120)\n const holdOf = () => numberOr(options.hold, DEFAULT_HOLD, 0, 60000)\n const startOf = () =>\n numberOr(options.startDelay, DEFAULT_START_DELAY, 0, 60000)\n const jitterOf = () => numberOr(options.jitter, DEFAULT_JITTER, 0, 1)\n\n // Screen readers get the whole text once; the machine is decoration.\n const spoken = document.createElement(\"span\")\n Object.assign(spoken.style, {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n margin: \"-1px\",\n padding: \"0\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n })\n\n const stage = div({\n position: \"absolute\",\n inset: \"0\",\n overflow: \"hidden\",\n borderRadius: \"inherit\",\n pointerEvents: \"none\",\n })\n stage.setAttribute(\"aria-hidden\", \"true\")\n const viewport = div({ position: \"absolute\", inset: \"0\" })\n const page = div({\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n right: \"0\",\n whiteSpace: \"pre\",\n willChange: \"transform\",\n })\n const carriage = div({\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n width: \"100%\",\n willChange: \"transform\",\n })\n const rail = svgEl(\"svg\", { overflow: \"visible\" })\n rail.style.position = \"absolute\"\n rail.style.left = \"0\"\n rail.style.overflow = \"visible\"\n const head = div({\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n willChange: \"transform\",\n })\n const clamps = svgEl(\"svg\", { overflow: \"visible\" })\n clamps.style.position = \"absolute\"\n clamps.style.overflow = \"visible\"\n const ball = createTypeball()\n\n head.append(clamps, ball.el)\n carriage.append(rail, head)\n viewport.append(page)\n stage.append(viewport, carriage)\n root.append(spoken, stage)\n\n // Font metrics and frame, refreshed by `measure`.\n const m = {\n fs: 16,\n cw: 9.6,\n lh: 35,\n base: 22,\n width: 0,\n height: 0,\n padT: 0,\n padB: 0,\n margin: 0,\n columns: 1,\n radius: 16,\n /** Baseline to the ball's centre, which is also the rail's. */\n axis: 20,\n /** Cell centre to the outer edge of a rail clamp. */\n half: 24,\n }\n\n let strokes: TypewriterStroke[] = []\n /** Strokes on the page. */\n let pos = 0\n let cursor = { line: 0, col: 0 }\n let scroll = 0\n let lines: HTMLDivElement[] = []\n let phase: \"waiting\" | \"typing\" | \"done\" | \"feeding\" = \"waiting\"\n let rang = false\n let destroyed = false\n let reduce = false\n let onScreen = true\n let sound: Sound | null = null\n\n // One pending step, paused while the machine can't be seen.\n let pending: (() => void) | null = null\n let timer = 0\n let remaining = 0\n let dueAt = 0\n const active = () =>\n !destroyed && onScreen && !reduce && document.visibilityState !== \"hidden\"\n const arm = () => {\n dueAt = performance.now() + remaining\n timer = window.setTimeout(() => {\n timer = 0\n const run = pending\n pending = null\n run?.()\n }, remaining)\n }\n const schedule = (run: () => void, ms: number) => {\n window.clearTimeout(timer)\n timer = 0\n pending = run\n remaining = Math.max(0, ms)\n if (active()) arm()\n }\n const cancel = () => {\n window.clearTimeout(timer)\n timer = 0\n pending = null\n }\n /** The ball turns idly between pages, but only while anyone can see it. */\n const idle = () =>\n ball.drift((phase === \"waiting\" || phase === \"done\") && active())\n const sync = () => {\n if (active()) {\n if (pending && !timer) arm()\n } else if (timer) {\n window.clearTimeout(timer)\n timer = 0\n remaining = Math.max(0, dueAt - performance.now())\n }\n idle()\n }\n const setPhase = (next: typeof phase) => {\n phase = next\n idle()\n }\n\n const soundOn = () => {\n if (!options.sound) return null\n sound ??= createSound()\n return sound\n }\n\n const cellX = (col: number) => m.margin + col * m.cw\n const lineTop = (line: number) => m.padT + line * m.lh\n /** How far the paper must feed up to keep the carriage on `line` in frame. */\n const scrollFor = (line: number) => {\n const floor = m.height - Math.min(m.padB, m.fs * 0.6)\n return Math.max(0, lineTop(line) + m.base + m.axis + m.radius - floor)\n }\n\n const setTransition = (\n el: HTMLElement,\n ms: number,\n ease: string,\n delay = 0\n ) => {\n el.style.transition =\n ms > 0 ? `transform ${ms}ms ${ease} ${Math.round(delay)}ms` : \"none\"\n }\n const placeHead = (col: number, ms = 0, ease = \"linear\", delay = 0) => {\n setTransition(head, ms, ease, delay)\n head.style.transform = `translate3d(${cellX(col)}px,0,0)`\n }\n const placeCarriage = (line: number, ms = 0, ease = \"linear\") => {\n setTransition(carriage, ms, ease)\n const y = Math.round(lineTop(line) - scroll + m.base)\n carriage.style.transform = `translate3d(0,${y}px,0)`\n }\n const placePage = (ms = 0, ease = \"linear\") => {\n page.style.transition =\n ms > 0 ? `transform ${ms}ms ${ease}, opacity ${ms}ms ${ease}` : \"none\"\n page.style.transform = `translate3d(0,${-scroll}px,0)`\n viewport.style.maskImage = viewport.style.webkitMaskImage =\n scroll > 0\n ? `linear-gradient(to bottom, transparent 0, #000 ${Math.max(m.padT, m.lh * 0.8)}px)`\n : \"\"\n }\n\n const lineEl = (index: number) => {\n while (lines.length <= index) {\n const el = div({\n position: \"absolute\",\n left: \"0\",\n right: \"0\",\n top: `${lineTop(lines.length)}px`,\n height: `${m.lh}px`,\n })\n page.append(el)\n lines.push(el)\n }\n return lines[index]!\n }\n\n const letter = (stroke: Extract) => {\n const jitter = jitterOf()\n const n = Math.floor(stroke.src) * 4\n const el = document.createElement(\"span\")\n el.textContent = stroke.ch\n Object.assign(el.style, {\n position: \"absolute\",\n top: \"0\",\n left: `${cellX(stroke.col)}px`,\n width: `${m.cw}px`,\n height: `${m.lh}px`,\n lineHeight: `${m.lh}px`,\n textAlign: \"center\",\n })\n if (jitter > 0) {\n const dx = (hash(n) - 0.5) * jitter * 0.07 * m.fs\n const dy = (hash(n + 1) - 0.5) * jitter * 0.1 * m.fs\n const tilt = (hash(n + 2) - 0.5) * jitter * 3.2\n el.style.transform = `translate(${dx.toFixed(2)}px,${dy.toFixed(2)}px) rotate(${tilt.toFixed(2)}deg)`\n el.style.opacity = (1 - hash(n + 3) * jitter * 0.42).toFixed(3)\n }\n lineEl(stroke.line).append(el)\n return el\n }\n\n const after = (stroke: TypewriterStroke | undefined) => {\n if (!stroke) return { line: 0, col: 0 }\n return stroke.kind === \"char\"\n ? { line: stroke.line, col: stroke.col + 1 }\n : { line: stroke.line, col: 0 }\n }\n\n /** Paint the first `pos` strokes and park the carriage, without motion. */\n const paint = () => {\n page.replaceChildren()\n lines = []\n for (let i = 0; i < pos; i++) {\n const stroke = strokes[i]!\n if (stroke.kind === \"char\" && stroke.ch !== \" \") letter(stroke)\n }\n cursor = after(strokes[pos - 1])\n scroll = scrollFor(cursor.line)\n placePage()\n placeCarriage(cursor.line)\n placeHead(cursor.col)\n rang = cursor.col >= m.columns - BELL_CELLS\n }\n\n const drawRail = () => {\n const { width, fs, cw, columns } = m\n const h = Math.max(3, Math.round(RAIL_H * fs))\n rail.setAttribute(\"width\", String(width))\n rail.setAttribute(\"height\", String(h + 1))\n rail.setAttribute(\"viewBox\", `0 0 ${width} ${h + 1}`)\n rail.style.top = `${Math.round(m.axis - h / 2)}px`\n rail.replaceChildren()\n const g = svgEl(\"g\", { fill: \"none\", stroke: \"currentColor\" })\n g.append(\n svgEl(\"path\", {\n d: `M0 0.5H${width}M0 ${h + 0.5}H${width}`,\n \"stroke-opacity\": 0.3,\n \"stroke-width\": 1,\n })\n )\n if (options.ticks ?? true) {\n let minor = \"\"\n let major = \"\"\n for (let c = 0; c <= columns; c++) {\n const x = Math.round(m.margin + c * cw + cw / 2) + 0.5\n if (c % 5 === 0) major += `M${x} 1V${c % 10 === 0 ? h : h * 0.62}`\n else minor += `M${x} 1V${h * 0.36}`\n }\n g.append(\n svgEl(\"path\", { d: minor, \"stroke-opacity\": 0.22, \"stroke-width\": 1 }),\n svgEl(\"path\", { d: major, \"stroke-opacity\": 0.42, \"stroke-width\": 1 })\n )\n }\n // Margin stops: where the carriage returns to, and where the bell rings.\n const stopW = Math.max(2, Math.round(fs * 0.14))\n const stopH = h + Math.round(fs * 0.32)\n for (const x of [m.margin, m.margin + columns * cw]) {\n g.append(\n svgEl(\"rect\", {\n x: Math.round(x - stopW / 2),\n y: Math.round((h - stopH) / 2) + 0.5,\n width: stopW,\n height: stopH,\n rx: stopW / 2,\n fill: \"currentColor\",\n \"fill-opacity\": 0.55,\n stroke: \"none\",\n })\n )\n }\n rail.append(g)\n }\n\n /** The ball, and the two clamps that hold it on the rail. */\n const drawHead = () => {\n const { fs, cw, radius, axis, half } = m\n const box = radius + 2\n ball.layout(radius, radius * BALL_GLYPH, getComputedStyle(root))\n ball.el.style.left = `${cw / 2 - box}px`\n ball.el.style.top = `${axis - box}px`\n\n const railH = Math.max(3, Math.round(RAIL_H * fs))\n const w = Math.max(3, Math.round(CLAMP_W * fs))\n const h = railH + Math.round(fs * 0.42)\n const gap = Math.round(CLAMP_GAP * fs)\n clamps.setAttribute(\"width\", String(half * 2))\n clamps.setAttribute(\"height\", String(h))\n clamps.setAttribute(\"viewBox\", `${-half} ${-h / 2} ${half * 2} ${h}`)\n clamps.style.left = `${cw / 2 - half}px`\n clamps.style.top = `${axis - h / 2}px`\n clamps.replaceChildren()\n for (const side of [-1, 1]) {\n const inner = radius + gap\n const x = side < 0 ? -inner - w : inner\n clamps.append(\n // The collar that grips the rail…\n svgEl(\"rect\", {\n x,\n y: -h / 2,\n width: w,\n height: h,\n rx: Math.min(w / 2, fs * 0.12),\n fill: \"currentColor\",\n }),\n // …and the axle stub it holds the ball by.\n svgEl(\"rect\", {\n x: side < 0 ? -inner : radius - 1,\n y: -Math.max(1, railH * 0.25),\n width: gap + 1,\n height: Math.max(2, railH * 0.5),\n fill: \"currentColor\",\n })\n )\n }\n }\n\n const measure = () => {\n const cs = getComputedStyle(root)\n const fs = parseFloat(cs.fontSize) || 16\n const probe = document.createElement(\"span\")\n probe.textContent = \"0000000000\"\n Object.assign(probe.style, {\n position: \"absolute\",\n visibility: \"hidden\",\n whiteSpace: \"pre\",\n })\n const marker = document.createElement(\"span\")\n Object.assign(marker.style, {\n display: \"inline-block\",\n width: \"0\",\n height: \"0\",\n verticalAlign: \"baseline\",\n })\n const lh = Math.round(fs * LEADING)\n const row = div({\n position: \"absolute\",\n visibility: \"hidden\",\n lineHeight: `${lh}px`,\n height: `${lh}px`,\n })\n row.append(\"x\", marker)\n stage.append(probe, row)\n const cw = probe.getBoundingClientRect().width / 10 || fs * 0.6\n const base = marker.offsetTop\n probe.remove()\n row.remove()\n\n const width = root.clientWidth\n const height = root.clientHeight\n const padL = parseFloat(cs.paddingLeft) || 0\n const padR = parseFloat(cs.paddingRight) || 0\n const radius = Math.round(fs * BALL_R)\n const half = Math.ceil(\n radius + Math.round(CLAMP_GAP * fs) + Math.round(CLAMP_W * fs)\n )\n const reach = half - cw / 2\n const edge = fs * 0.5\n // The carriage never hangs off the frame, even at the margins.\n const margin = Math.max(padL, Math.ceil(reach + edge))\n const fit = Math.floor(\n Math.min(width - padR - margin, width - edge - reach - cw - margin) / cw\n )\n const wanted = Math.floor(numberOr(options.columns, 0, 0, 1000))\n const columns = Math.max(1, wanted > 0 ? Math.min(wanted, fit) : fit)\n\n const changed =\n fs !== m.fs ||\n cw !== m.cw ||\n lh !== m.lh ||\n columns !== m.columns ||\n width !== m.width\n Object.assign(m, {\n fs,\n cw,\n lh,\n base,\n width,\n height,\n padT: parseFloat(cs.paddingTop) || 0,\n padB: parseFloat(cs.paddingBottom) || 0,\n margin,\n columns,\n radius,\n axis: Math.round(BALL_GAP * fs + radius),\n half,\n })\n return changed\n }\n\n /** Lay the text out again for the current frame, keeping the place. */\n const relayout = () => {\n const at = strokes[pos - 1]?.src ?? -1\n strokes = layoutTypewriter(textOf(), m.columns)\n pos = phase === \"done\" ? strokes.length : 0\n if (phase !== \"done\") {\n while (pos < strokes.length && strokes[pos]!.src <= at) pos += 1\n }\n drawRail()\n drawHead()\n paint()\n }\n\n const pace = (ch: string) => {\n const base = 1000 / speedOf()\n if (!(options.humanize ?? true)) return base\n let factor = 0.55 + Math.random() * 0.9\n if (ch === \" \") factor *= 1.15\n else if (/[,;:]/.test(ch)) factor *= 2.4\n else if (/[.!?]/.test(ch)) factor *= 3.4\n if (Math.random() < 0.04) factor *= 2.6\n return base * factor\n }\n\n /** Spin the glyph round, strike, then step the carriage a cell right. */\n const strike = (stroke: Extract) => {\n const base = 1000 / speedOf()\n const spin = stroke.ch === \" \" ? 0 : clamp(base * 0.4, 16, 70)\n const step = Math.min(90, base * 0.7)\n if (stroke.ch !== \" \") {\n ball.select(stroke.ch, spin)\n letter(stroke).animate([{ opacity: 0 }, {}], {\n duration: Math.min(70, step),\n delay: spin,\n easing: \"ease-out\",\n fill: \"backwards\",\n })\n ball.el.animate(\n [\n { transform: \"translateY(0)\" },\n { transform: `translateY(${(-m.fs * 0.22).toFixed(2)}px)` },\n { transform: \"translateY(0)\" },\n ],\n {\n duration: Math.min(110, base * 0.9),\n delay: spin * 0.55,\n easing: \"cubic-bezier(.3,.7,.4,1)\",\n }\n )\n }\n cursor = { line: stroke.line, col: stroke.col + 1 }\n placeHead(cursor.col, step, \"cubic-bezier(.2,.8,.2,1)\", spin)\n const audio = soundOn()\n audio?.key(stroke.ch === \" \")\n if (\n !rang &&\n cursor.col >= m.columns - BELL_CELLS &&\n m.columns > BELL_CELLS * 2\n ) {\n rang = true\n audio?.bell()\n }\n }\n\n /** Whirl home along the rail and drop a line. */\n const carriageReturn = (\n stroke: Extract\n ) => {\n const glide = clamp(200 + stroke.from * 7, 240, 620)\n const drop = 260\n cursor = { line: stroke.line, col: 0 }\n rang = false\n const next = Math.max(scroll, scrollFor(cursor.line))\n if (next !== scroll) {\n scroll = next\n placePage(drop, \"cubic-bezier(.3,.7,.2,1)\")\n }\n placeCarriage(cursor.line, drop, \"cubic-bezier(.3,.7,.2,1)\")\n placeHead(0, glide, \"cubic-bezier(.65,0,.25,1)\")\n if (stroke.from > 0) {\n ball.whirl(-(0.5 + stroke.from / Math.max(1, m.columns)), glide)\n }\n soundOn()?.carriage(glide)\n return glide\n }\n\n const finish = () => {\n setPhase(\"done\")\n options.onDone?.()\n if (options.loop ?? true) schedule(feedOut, holdOf())\n }\n\n const step = () => {\n if (pos >= strokes.length) {\n finish()\n return\n }\n if (phase !== \"typing\") setPhase(\"typing\")\n const stroke = strokes[pos++]!\n if (stroke.kind === \"char\") {\n strike(stroke)\n schedule(step, pace(stroke.ch))\n } else {\n const glide = carriageReturn(stroke)\n schedule(step, glide + pace(\" \") * 0.8)\n }\n }\n\n /** Pull the finished sheet up and out, bring the carriage home, go again. */\n const feedOut = () => {\n setPhase(\"feeding\")\n const ms = 820\n const pull = \"cubic-bezier(.55,0,.75,.2)\"\n const lift = lineTop(cursor.line + 1) + m.lh\n page.style.transition = `transform ${ms}ms ${pull}, opacity ${ms}ms ease-in`\n page.style.transform = `translate3d(0,${-scroll - lift}px,0)`\n page.style.opacity = \"0\"\n scroll = 0\n // Same curve as the sheet, which travels further, so the carriage rises\n // behind the last line instead of through it.\n placeCarriage(0, ms, pull)\n placeHead(0, ms, \"cubic-bezier(.65,0,.35,1)\")\n if (cursor.col > 0) ball.whirl(-1, ms)\n schedule(() => {\n pos = 0\n page.style.opacity = \"\"\n paint()\n setPhase(\"waiting\")\n schedule(step, startOf() * 0.6)\n }, ms + 60)\n }\n\n const start = () => {\n cancel()\n page.style.opacity = \"\"\n strokes = layoutTypewriter(textOf(), m.columns)\n spoken.textContent = textOf()\n if (reduce) {\n pos = strokes.length\n setPhase(\"done\")\n paint()\n return\n }\n pos = 0\n setPhase(\"waiting\")\n paint()\n schedule(step, startOf())\n }\n\n measure()\n drawRail()\n drawHead()\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n reduce = mqReduce.matches\n const onReduce = () => {\n reduce = mqReduce.matches\n start()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n start()\n\n const onResize = () => {\n if (measure()) relayout()\n else {\n scroll = Math.max(scroll, scrollFor(cursor.line))\n placePage()\n placeCarriage(cursor.line)\n }\n }\n const ro = new ResizeObserver(onResize)\n ro.observe(root)\n // Webfonts can land after mount and change the cell width.\n void document.fonts?.ready.then(() => {\n if (!destroyed) onResize()\n })\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n sync()\n })\n io.observe(root)\n const onVisibility = () => sync()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (textOf() !== (prev.text ?? DEFAULT_TEXT)) {\n start()\n return\n }\n if (options.columns !== prev.columns) {\n measure()\n relayout()\n } else if (options.jitter !== prev.jitter) {\n paint()\n }\n if (options.ticks !== prev.ticks) drawRail()\n const loop = options.loop ?? true\n if (loop !== (prev.loop ?? true) && phase === \"done\") {\n if (loop) schedule(feedOut, holdOf())\n else cancel()\n }\n },\n restart: start,\n destroy() {\n destroyed = true\n cancel()\n ball.destroy()\n ro.disconnect()\n io.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n document.removeEventListener(\"visibilitychange\", onVisibility)\n sound?.close()\n spoken.remove()\n stage.remove()\n },\n }\n}\n\nexport type TypewriterProps = TypewriterOptions & {\n className?: string\n}\n\n/**\n * A typewriter on the page — a typeball rides a rail across the frame,\n * spins each letter to the front, strikes, steps right, and at the margin\n * whirls home and drops a line. Ink follows `currentColor`. Honors\n * `prefers-reduced-motion`.\n */\nexport function Typewriter({\n className,\n text = DEFAULT_TEXT,\n speed = DEFAULT_SPEED,\n humanize = true,\n loop = true,\n hold = DEFAULT_HOLD,\n startDelay = DEFAULT_START_DELAY,\n columns = 0,\n ticks = true,\n jitter = DEFAULT_JITTER,\n sound = false,\n onDone,\n}: TypewriterProps) {\n const rootRef = useRef(null)\n const instanceRef = useRef(null)\n const onDoneRef = useRef(onDone)\n\n useEffect(() => {\n onDoneRef.current = onDone\n }, [onDone])\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n instanceRef.current = createTypewriter(root, {\n text,\n speed,\n humanize,\n loop,\n hold,\n startDelay,\n columns,\n ticks,\n jitter,\n sound,\n onDone: () => onDoneRef.current?.(),\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 speed,\n humanize,\n loop,\n hold,\n startDelay,\n columns,\n ticks,\n jitter,\n sound,\n })\n }, [\n text,\n speed,\n humanize,\n loop,\n hold,\n startDelay,\n columns,\n ticks,\n jitter,\n sound,\n ])\n\n return (\n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/typewriter.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/registry.json b/registry.json index 545a227..d1f7521 100644 --- a/registry.json +++ b/registry.json @@ -16,6 +16,7 @@ "registry/shader-fire/registry.json", "registry/shader-gradient/registry.json", "registry/shader-sky/registry.json", - "registry/stretchy-footer/registry.json" + "registry/stretchy-footer/registry.json", + "registry/typewriter/registry.json" ] } diff --git a/registry/typewriter/registry.json b/registry/typewriter/registry.json new file mode 100644 index 0000000..29db8c3 --- /dev/null +++ b/registry/typewriter/registry.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "typewriter", + "type": "registry:ui", + "title": "Typewriter", + "description": "Text typed onto the page by a typeball riding a rail — it spins each letter to the front, strikes, steps right, and at the margin whirls home and drops a line.", + "files": [ + { + "path": "typewriter.tsx", + "type": "registry:ui", + "target": "components/ui/typewriter.tsx" + } + ] + }, + { + "name": "typewriter-svelte", + "type": "registry:ui", + "title": "Typewriter", + "description": "Text typed onto the page by a typeball riding a rail — it spins each letter to the front, strikes, steps right, and at the margin whirls home and drops a line.", + "files": [ + { + "path": "typewriter.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/typewriter.svelte" + } + ] + } + ] +} diff --git a/registry/typewriter/typewriter-demo.tsx b/registry/typewriter/typewriter-demo.tsx new file mode 100644 index 0000000..0ec9709 --- /dev/null +++ b/registry/typewriter/typewriter-demo.tsx @@ -0,0 +1,150 @@ +"use client" + +import { useId, useState } from "react" +import { RiRestartLine } from "@remixicon/react" + +import { Button } from "@/components/ui/button" +import { + ComponentControls, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { + DEFAULT_JITTER, + DEFAULT_SPEED, + DEFAULT_TEXT, + Typewriter, +} from "@/registry/typewriter/typewriter" + +const DEFAULTS = { + text: DEFAULT_TEXT, + speed: DEFAULT_SPEED, + jitter: DEFAULT_JITTER, + columns: 0, + humanize: true, + ticks: true, + loop: true, + sound: false, +} + +export function TypewriterDemo() { + const textId = useId() + const [take, setTake] = useState(0) + const { props, updateProp, resetProps, hasChanges } = + usePreviewProps(DEFAULTS) + + return ( + <> + +
+ +
+
+ + +
+
+ + +
+
- import { onMount } from "svelte" import { - createShaderFire, + createShaderAnimeFire, DARK_FALLBACK, DEFAULT_EMBERS, DEFAULT_HEIGHT, @@ -12,15 +12,15 @@ DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, - type ShaderFireInstance, - type ShaderFireOptions, - } from "./shader-fire-vanilla" + type ShaderAnimeFireInstance, + type ShaderAnimeFireOptions, + } from "./shader-anime-fire-vanilla" function cn(...parts: Array) { return parts.filter(Boolean).join(" ") } - interface Props extends Omit { + interface Props extends Omit { class?: string } @@ -39,7 +39,7 @@ let canvas: HTMLCanvasElement | undefined = $state() let isDark = $state(false) - let instance: ShaderFireInstance | null = null + let instance: ShaderAnimeFireInstance | null = null onMount(() => { const sync = () => { @@ -60,7 +60,7 @@ mq.removeEventListener("change", sync) } } - instance = createShaderFire(canvas, { + instance = createShaderAnimeFire(canvas, { colors, speed, intensity, @@ -104,7 +104,7 @@ \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/shader-fire.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/registry.json b/registry.json index 82f1c61..545a227 100644 --- a/registry.json +++ b/registry.json @@ -13,6 +13,7 @@ "registry/phosphor-score/registry.json", "registry/radiant-lines/registry.json", "registry/shader-anime-fire/registry.json", + "registry/shader-fire/registry.json", "registry/shader-gradient/registry.json", "registry/shader-sky/registry.json", "registry/stretchy-footer/registry.json" diff --git a/registry/shader-fire/registry.json b/registry/shader-fire/registry.json new file mode 100644 index 0000000..a32ade4 --- /dev/null +++ b/registry/shader-fire/registry.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "shader-fire", + "type": "registry:ui", + "title": "Shader Fire", + "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "files": [ + { + "path": "shader-fire.tsx", + "type": "registry:ui", + "target": "components/ui/shader-fire.tsx" + } + ] + }, + { + "name": "shader-fire-svelte", + "type": "registry:ui", + "title": "Shader Fire", + "description": "Sparse 2D WebGL fire wash for landing heroes — tongues rise from the bottom, theme-aware, optional pointer heat.", + "files": [ + { + "path": "shader-fire.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/shader-fire.svelte" + } + ], + "dependencies": [] + } + ] +} diff --git a/registry/shader-fire/shader-fire-demo.tsx b/registry/shader-fire/shader-fire-demo.tsx new file mode 100644 index 0000000..48e12f2 --- /dev/null +++ b/registry/shader-fire/shader-fire-demo.tsx @@ -0,0 +1,194 @@ +"use client" + +import { useLayoutEffect, useMemo } from "react" + +import { Button } from "@/components/ui/button" +import { + ComponentControls, + ControlColors, + 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_COLORS, + LIGHT_COLORS, + ShaderFire, +} from "@/registry/shader-fire/shader-fire" + +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]!)) + ) +} + +function isStockPalette(colors: string[]) { + return colorsEqual(colors, LIGHT_COLORS) || colorsEqual(colors, DARK_COLORS) +} + +export function ShaderFireDemo() { + const theme = useHydratedTheme() + const palette = theme === "dark" ? DARK_COLORS : LIGHT_COLORS + + const defaults = useMemo( + () => ({ + speed: 0.55, + intensity: 0.55, + height: 0.45, + interactive: true, + overlay: true, + dither: false, + pixelSize: 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 useAutoTheme = isStockPalette(props.colors) + + return ( + <> + +
+ + {props.overlay ? ( + <> +
+
+

+ Atmosphere +

+

+ Heat under the headline +

+

+ Sparse tongues rise from the bottom. Copy stays in the quiet + middle; the fire stays a wash. +

+
+ + +
+
+ + ) : null} +
+ + + + updateProp("colors", colors)} + /> + updateProp("speed", v)} + /> + updateProp("intensity", v)} + /> + updateProp("height", v)} + /> + updateProp("overlay", v)} + /> + updateProp("interactive", v)} + /> + updateProp("dither", v)} + /> + updateProp("pixelSize", v)} + /> + + + ) +} diff --git a/registry/shader-fire/shader-fire-vanilla.ts b/registry/shader-fire/shader-fire-vanilla.ts new file mode 100644 index 0000000..c6a6736 --- /dev/null +++ b/registry/shader-fire/shader-fire-vanilla.ts @@ -0,0 +1,483 @@ +export type ShaderFireTheme = "light" | "dark" | "auto" + +export type ShaderFireOptions = { + /** Ember / flame / highlight hex */ + colors?: string[] + /** Rise speed. Default 0.55 */ + speed?: number + /** Coverage 0–1. Default 0.55 */ + intensity?: number + /** How far tongues climb 0–1. Default 0.45 */ + height?: number + /** Local heat under the pointer. Default true */ + interactive?: boolean + /** Ordered Bayer pixels instead of a soft wash. Default false */ + dither?: boolean + /** Dither cell size in CSS px. Default `1` */ + pixelSize?: number + /** + * Palette mode. Default `auto` follows shadcn / next-themes + * (`html.dark` class) so light and dark swap with the site theme. + */ + theme?: ShaderFireTheme + /** Fires whenever resolved dark mode changes (CSS fallback). */ + onThemeChange?: (dark: boolean) => void +} + +export type ShaderFireInstance = { + setOptions: (options: Partial) => void + destroy: () => void +} + +/** Warm ink on paper — sienna / amber / dusty peach */ +export const LIGHT_COLORS = ["#9C3A24", "#C96A32", "#E6C4A0"] +/** Visible coals on slate — ember / flame / highlight */ +export const DARK_COLORS = ["#A33A18", "#D4682A", "#E8B45A"] + +export const LIGHT_FALLBACK = { + backgroundColor: "#FCFBF9", + backgroundImage: [ + "radial-gradient(80% 42% at 50% 108%, #9C3A24 0%, transparent 70%)", + "radial-gradient(36% 28% at 22% 100%, #C96A32 0%, transparent 72%)", + "radial-gradient(32% 24% at 78% 100%, #C96A32 0%, transparent 72%)", + "radial-gradient(22% 16% at 50% 100%, #E6C4A0 0%, transparent 70%)", + ].join(", "), +} as const + +export const DARK_FALLBACK = { + backgroundColor: "#08090C", + backgroundImage: [ + "radial-gradient(80% 42% at 50% 108%, #A33A18 0%, transparent 70%)", + "radial-gradient(36% 28% at 22% 100%, #D4682A 0%, transparent 72%)", + "radial-gradient(32% 24% at 78% 100%, #D4682A 0%, transparent 72%)", + "radial-gradient(22% 16% at 50% 100%, #E8B45A 0%, transparent 70%)", + ].join(", "), +} as const + +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 float u_time; +uniform float u_speed; +uniform float u_intensity; +uniform float u_height; +uniform float u_dark; +uniform float u_dither; +uniform float u_pixelSize; +uniform vec3 u_c1; +uniform vec3 u_c2; +uniform vec3 u_c3; +uniform vec2 u_mouse; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + mat2 m = mat2(1.6, 1.2, -1.2, 1.6); + for (int i = 0; i < 5; i++) { + v += a * noise(p); + p = m * p; + a *= 0.5; + } + return v; +} + +// Classic Bayer (WebGL1-safe, no bitwise ops) +float bayer2(vec2 c) { + return mod(c.x * 2.0 + c.y * 3.0, 4.0); +} + +float bayer4(vec2 p) { + vec2 c = mod(floor(p), 4.0); + vec2 hi = floor(c * 0.5); + vec2 lo = mod(c, 2.0); + return bayer2(lo) * 4.0 + bayer2(hi); +} + +float bayer8(vec2 p) { + vec2 c = mod(floor(p), 8.0); + vec2 hi = floor(c * 0.5); + vec2 lo = mod(c, 2.0); + return (bayer4(hi) * 4.0 + bayer2(lo) + 0.5) / 64.0; +} + +float fireField(vec2 uv, float aspect, float t) { + float climb = mix(0.22, 0.82, clamp(u_height, 0.0, 1.0)); + + // Column space — sway as they rise + vec2 q = vec2(uv.x * aspect * 1.85, uv.y * 1.55 - t * 0.48); + q.x += sin(uv.y * 7.0 + t * 1.6) * 0.045 * (0.25 + uv.y); + + // Two-pass domain warp — curling tongues, not blobs + vec2 w1 = vec2( + fbm(q * 1.7 + vec2(t * 0.28, t * 0.22)), + fbm(q * 1.7 + vec2(-t * 0.24, t * 0.31) + 5.2) + ); + q += (w1 - 0.5) * 0.58; + vec2 w2 = vec2( + fbm(q * 3.4 - vec2(t * 0.52, 0.0)), + fbm(q * 3.4 + vec2(0.0, t * 0.44) + 2.7) + ); + q += (w2 - 0.5) * 0.26; + + float shape = fbm(q * 2.35); + float detail = fbm(q * 7.2 + vec2(0.0, t * 1.7)); + float flicker = 0.9 + 0.1 * sin(t * 13.0 + uv.x * 9.0 + shape * 6.0); + float field = (shape * 0.68 + detail * 0.32) * flicker; + + // Hotter, wider base; thinner breakaways as they climb + float taper = pow(clamp(1.0 - uv.y / max(climb, 0.05), 0.0, 1.0), 1.35); + float core = pow(clamp(1.0 - uv.y / max(climb * 0.42, 0.04), 0.0, 1.0), 2.1); + field = field * taper + core * shape * 0.38; + + float md = length((uv - u_mouse) * vec2(aspect, 1.0)); + field += exp(-md * 6.8) * 0.28 * (0.55 + 0.45 * shape); + + field *= smoothstep(0.0, 0.05, uv.y + 0.015); + field = smoothstep(0.22, 0.74, field); + + float intensity = mix(0.34, 1.18, clamp(u_intensity, 0.0, 1.0)); + return clamp(field * intensity, 0.0, 1.0); +} + +void main() { + float aspect = u_resolution.x / max(u_resolution.y, 1.0); + float t = u_time * u_speed; + float px = max(u_pixelSize, 1.0); + + vec2 sampleUv = gl_FragCoord.xy / u_resolution.xy; + vec2 cell = floor(gl_FragCoord.xy / px); + if (u_dither > 0.5) { + sampleUv = (cell + 0.5) * px / u_resolution.xy; + } + + float field = fireField(sampleUv, aspect, t); + + if (u_dither > 0.5) { + float bit = step(bayer8(cell), field); + field = bit * mix(0.45, 1.0, field); + } + + vec3 ember = u_c1; + vec3 flame = u_c2; + vec3 highlight = u_c3; + vec3 tint = mix(ember, flame, smoothstep(0.08, 0.48, field)); + tint = mix(tint, highlight, pow(smoothstep(0.42, 0.96, field), 1.55)); + + vec3 paper = u_dark < 0.5 + ? vec3(0.992, 0.986, 0.978) + : vec3(0.03, 0.032, 0.042); + // Light: watercolor on paper — more air, less orange stain + float cover = u_dither > 0.5 + ? field + : field * (u_dark < 0.5 ? 0.68 : 0.84); + vec3 col = mix(paper, tint, clamp(cover, 0.0, 1.0)); + if (u_dark < 0.5 && u_dither < 0.5) { + float haze = field * (1.0 - field) * 0.12; + col = mix(col, paper, haze); + } + + if (u_dither < 0.5) { + col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.01; + } + + gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} +` + +function isDev() { + return typeof process !== "undefined" && process.env?.NODE_ENV !== "production" +} + +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 [0.77, 0.38, 0.18] + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] +} + +/** 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: ShaderFireTheme): 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( + "ShaderFire: shader failed to compile\n", + gl.getShaderInfoLog(shader) + ) + } + gl.deleteShader(shader) + return null + } + return shader +} + +/** + * Sparse 2D fire wash — tongues rise from the bottom behind UI. + * Theme-aware light / dusk. + */ +export function createShaderFire( + canvas: HTMLCanvasElement, + initial: ShaderFireOptions = {} +): ShaderFireInstance | null { + let options: Required< + Pick< + ShaderFireOptions, + | "speed" + | "intensity" + | "height" + | "interactive" + | "dither" + | "pixelSize" + | "theme" + > + > & + ShaderFireOptions = { + speed: 0.55, + intensity: 0.55, + height: 0.45, + interactive: true, + dither: false, + pixelSize: 1, + theme: "auto", + ...initial, + } + + const mouse = { x: 0.5, y: 0.12 } + const targetMouse = { x: 0.5, y: 0.12 } + let reduce = false + let dark = resolveDark(options.theme ?? "auto") + options.onThemeChange?.(dark) + + const gl = canvas.getContext("webgl", { + alpha: false, + antialias: false, + depth: false, + stencil: false, + powerPreference: "high-performance", + }) + if (!gl) return null + + const vs = compile(gl, gl.VERTEX_SHADER, VERT) + const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG) + if (!vs || !fs) { + if (vs) gl.deleteShader(vs) + if (fs) gl.deleteShader(fs) + return null + } + + const program = gl.createProgram() + if (!program) { + gl.deleteShader(vs) + gl.deleteShader(fs) + return null + } + gl.attachShader(program, vs) + gl.attachShader(program, fs) + gl.linkProgram(program) + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + if (isDev()) { + console.warn( + "ShaderFire: program failed to link\n", + gl.getProgramInfoLog(program) + ) + } + gl.deleteProgram(program) + gl.deleteShader(vs) + gl.deleteShader(fs) + return null + } + gl.useProgram(program) + + const 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) + + const uResolution = gl.getUniformLocation(program, "u_resolution") + const uTime = gl.getUniformLocation(program, "u_time") + const uSpeed = gl.getUniformLocation(program, "u_speed") + const uIntensity = gl.getUniformLocation(program, "u_intensity") + const uHeight = gl.getUniformLocation(program, "u_height") + const uDark = gl.getUniformLocation(program, "u_dark") + const uC1 = gl.getUniformLocation(program, "u_c1") + const uC2 = gl.getUniformLocation(program, "u_c2") + const uC3 = gl.getUniformLocation(program, "u_c3") + const uMouse = gl.getUniformLocation(program, "u_mouse") + const uDither = gl.getUniformLocation(program, "u_dither") + const uPixelSize = gl.getUniformLocation(program, "u_pixelSize") + + let raf = 0 + let running = true + const start = performance.now() + let frozenTime = 0 + + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + 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)) + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + gl.viewport(0, 0, canvas.width, canvas.height) + } + + resize() + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + const onReduce = () => { + reduce = mqReduce.matches + } + onReduce() + mqReduce.addEventListener("change", onReduce) + + const syncTheme = () => { + const next = resolveDark(options.theme ?? "auto") + if (next === dark) return + dark = next + options.onThemeChange?.(dark) + } + 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) return + const parent = canvas.parentElement + if (!parent) return + const rect = parent.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + targetMouse.x = (e.clientX - rect.left) / rect.width + targetMouse.y = 1 - (e.clientY - rect.top) / rect.height + } + window.addEventListener("pointermove", onMove, { passive: true }) + + const tick = (now: number) => { + if (!running) return + + if (reduce) { + if (frozenTime === 0) frozenTime = (now - start) / 1000 + } else { + frozenTime = 0 + } + + const time = reduce ? frozenTime : (now - start) / 1000 + const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS) + const palette = [0, 1, 2].map((i) => + hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!) + ) + + mouse.x += (targetMouse.x - mouse.x) * 0.05 + mouse.y += (targetMouse.y - mouse.y) * 0.05 + + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const px = Math.max(1, options.pixelSize ?? 1) * dpr + + gl.uniform2f(uResolution, canvas.width, canvas.height) + gl.uniform1f(uTime, time) + gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.55)) + gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.55))) + gl.uniform1f(uHeight, Math.min(1, Math.max(0, options.height ?? 0.45))) + gl.uniform1f(uDark, dark ? 1 : 0) + gl.uniform1f(uDither, options.dither ? 1 : 0) + gl.uniform1f(uPixelSize, px) + gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2]) + gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2]) + gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2]) + gl.uniform2f( + uMouse, + options.interactive ? mouse.x : 0.5, + options.interactive ? mouse.y : 0.12 + ) + + gl.drawArrays(gl.TRIANGLES, 0, 6) + raf = requestAnimationFrame(tick) + } + + raf = requestAnimationFrame(tick) + + return { + setOptions(next) { + options = { ...options, ...next } + syncTheme() + }, + destroy() { + running = false + cancelAnimationFrame(raf) + ro.disconnect() + mo.disconnect() + mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) + window.removeEventListener("pointermove", onMove) + gl.deleteProgram(program) + gl.deleteShader(vs) + gl.deleteShader(fs) + gl.deleteBuffer(buf) + }, + } +} diff --git a/registry/shader-fire/shader-fire.svelte b/registry/shader-fire/shader-fire.svelte new file mode 100644 index 0000000..93969ed --- /dev/null +++ b/registry/shader-fire/shader-fire.svelte @@ -0,0 +1,106 @@ + + + + + diff --git a/registry/shader-fire/shader-fire.tsx b/registry/shader-fire/shader-fire.tsx new file mode 100644 index 0000000..5fae2f1 --- /dev/null +++ b/registry/shader-fire/shader-fire.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useEffect, useRef, useState } from "react" + +import { cn } from "@/lib/utils" + +import { + createShaderFire, + DARK_FALLBACK, + LIGHT_FALLBACK, + resolveDark, + type ShaderFireInstance, + type ShaderFireOptions, +} from "./shader-fire-vanilla" + +export { + DARK_COLORS, + DARK_FALLBACK, + LIGHT_COLORS, + LIGHT_FALLBACK, +} from "./shader-fire-vanilla" +export type { + ShaderFireInstance, + ShaderFireOptions, + ShaderFireTheme, +} from "./shader-fire-vanilla" + +export type ShaderFireProps = Omit & { + className?: string +} + +/** + * Sparse 2D fire wash — tongues rise from the bottom behind UI. + * Theme-aware light / dusk. + */ +export function ShaderFire({ + className, + colors, + speed = 0.55, + intensity = 0.55, + height = 0.45, + interactive = true, + dither = false, + pixelSize = 1, + theme = "auto", +}: ShaderFireProps) { + const canvasRef = useRef(null) + const instanceRef = useRef(null) + const [isDark, setIsDark] = useState(false) + + useEffect(() => { + const sync = () => setIsDark(resolveDark(theme)) + sync() + const mo = new MutationObserver(sync) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }) + const mq = window.matchMedia("(prefers-color-scheme: dark)") + mq.addEventListener("change", sync) + return () => { + mo.disconnect() + mq.removeEventListener("change", sync) + } + }, [theme]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + instanceRef.current = createShaderFire(canvas, { + colors, + speed, + intensity, + height, + interactive, + dither, + pixelSize, + theme, + onThemeChange: setIsDark, + }) + 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({ + colors, + speed, + intensity, + height, + interactive, + dither, + pixelSize, + theme, + onThemeChange: setIsDark, + }) + }, [colors, speed, intensity, height, interactive, dither, pixelSize, theme]) + + const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK + + return ( +
+ +
+ ) +} diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index 73d9b23..b570dee 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -92,7 +92,8 @@ flowchart TD pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} atmo -->|Quiet wash| sg[shader-gradient] - atmo -->|Fire from the bottom| sf[shader-anime-fire] + atmo -->|Cel-shaded fire from the bottom| saf[shader-anime-fire] + atmo -->|Soft fire wash from the bottom| sf[shader-fire] atmo -->|Sky or rain, optional glass| ss[shader-sky] atmo -->|ASCII mouse trails| af[ascii-fluid] atmo -->|Hyperspace streaks, scroll warp| rl[radiant-lines] @@ -110,6 +111,7 @@ Disambiguation that agents get wrong: | Color picker, swatch, hex, hue, alpha, eyedropper | `gooey-color-picker` | Any shader. Shaders are not controls. | | Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | | Fire, embers, heat under a hero | `shader-anime-fire` | `dithered-404` unless the page is a 404 | +| Quiet heat wash behind copy | `shader-fire` | `shader-anime-fire` when it should be the show | | 404 that burns under the cursor | `dithered-404` | `shader-anime-fire` | | Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | Page leans while scrolling | `folio` | `stretchy-footer` (that one stretches, it does not tilt) | @@ -132,7 +134,7 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `ShaderAnimeFire`, `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`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `RadiantLines` is transparent and warps with scroll. Inside an overflow div, pass the scroller (`containerRef` in React, `container` in Svelte) and make the canvas `sticky top-0 h-svh`. Omit the scroller to use the window. @@ -153,7 +155,7 @@ That matches shadcn / `next-themes` with `attribute="class"`. There is no shared Exceptions: -- Passing `colors` to `ShaderGradient`, `ShaderAnimeFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. +- Passing `colors` to `ShaderGradient`, `ShaderAnimeFire`, `ShaderFire`, or `ShaderSky` replaces the stock palette and does not swap with dark mode. Omit `colors` to get the light/dark pair. - Passing `color` (and `backgroundColor` where it exists) overrides ink. Omit it to follow the theme. - `PhosphorScore` canvas is transparent in both themes; the parent paints the background. - `Folio` tilt peaks at an internal 16°. It is not a prop. The lean follows wheel speed, so trackpads and mice lean alike. Do not add a tilt prop that does not exist. `playFolioDemo` only previews the lean; it is not the interaction. @@ -172,6 +174,7 @@ Categories match `content/docs/components/meta.json`. | `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | | `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | | `shader-anime-fire` | Shaders | Cel-shaded flames and sparks rising from the bottom | React + Svelte | +| `shader-fire` | Shaders | Sparse fire tongues rising from the bottom | React + Svelte | | `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | | `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | React + Svelte | | `live-orb` | Characters | Lit sphere; eyes follow the pointer | React + Svelte | diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 73382bc..a8de6e0 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -170,6 +170,28 @@ type ShaderAnimeFireProps = { Same `colors ?? theme palette` rule. Fallbacks exported as `LIGHT_FALLBACK` / `DARK_FALLBACK`. +## shader-fire + +```ts +type ShaderFireProps = { + colors?: string[] // ember, flame, highlight + speed?: number // 0.55 + intensity?: number // 0.55 + height?: number // 0.45 + interactive?: boolean // true + dither?: boolean // false + pixelSize?: number // 1 + theme?: "light" | "dark" | "auto" + className?: string +} +``` + +`LIGHT_COLORS`: `#9C3A24`, `#C96A32`, `#E6C4A0`. + +`DARK_COLORS`: `#A33A18`, `#D4682A`, `#E8B45A`. + +Same `colors ?? theme palette` rule. Fallbacks exported as `LIGHT_FALLBACK` / `DARK_FALLBACK`. + ## shader-sky ```ts diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 6f085ad..69b7c79 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -149,7 +149,7 @@ Shader. Cel-shaded flames lick up from the bottom edge, sparks drift above. Embe Best fit: fire along the floor of a hero. `dither` prints the bands as Bayer pixels. -Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). +Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). A quiet wash (`shader-fire`). Deps: none. Key props: `colors`, `speed` (0.6), `intensity` (1), `height` (0.45), `embers` (0.6), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. @@ -169,6 +169,32 @@ export function Hero() { Pitfalls: `interactive` makes a plume of flame reach up toward the pointer. `dither` defaults off. `embers={0}` turns sparks off. Custom `colors` do not follow theme. Light `#D8341A #F9731E #FFBA3A`. Dark `#B4200A #FF6512 #FFC04A`. +## shader-fire + +Shader. Sparse tongues from the bottom edge. Ember / flame / highlight. + +Best fit: heat behind a hero. `dither` turns the wash into Bayer pixels. + +Not this: a 404 (`dithered-404` owns the burning glyph and the fireball cursor). Bold graphic flames (`shader-anime-fire`). + +Deps: none. Key props: `colors`, `speed` (0.55), `intensity` (0.55), `height` (0.45), `interactive` (true), `dither` (false), `pixelSize` (1), `theme`. + +```tsx +"use client" +import { ShaderFire } from "@/components/ui/shader-fire" + +export function Hero() { + return ( +
+ +
Your content
+
+ ) +} +``` + +Pitfalls: `interactive` adds heat under the pointer. `dither` defaults off. Custom `colors` do not follow theme. Light `#9C3A24 #C96A32 #E6C4A0`. Dark `#A33A18 #D4682A #E8B45A`. + ## shader-sky Shader. Daytime clouds, or a gray rain ceiling in dark mode. Optional dotted glass film. diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index 42d5bb9..463b4c9 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -34,6 +34,7 @@ Swap the component, keep the shell: | --- | --- | --- | | Fire along the floor | `ShaderAnimeFire` | `height={0.45}` | | Pixel fire | `ShaderAnimeFire` | `dither pixelSize={3}` | +| Soft heat wash | `ShaderFire` | defaults | | Blue sky | `ShaderSky` | leave `colors` unset | | Rain / dusk sky | `ShaderSky` | `theme="dark"` or `html.dark` | | Window glass | `ShaderSky` | `glass glassSize={7}` | From 4fbac12748bee90b64882e765404b770bf07b5c8 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Mon, 28 Sep 2026 13:39:22 +0530 Subject: [PATCH 31/49] Keep shader corners rounded in Firefox. Zen and other Firefox builds can hand an opaque WebGL canvas straight to the system compositor, which ignores rounded clips, so every shader showed square corners there while Chrome rounded them. Each WebGL background (Shader Gradient, Anime Fire, Fire, Sky and ASCII Fluid) now inherits its parent's radius and carries a solid mask on its root, which keeps the canvas in the page's own layer so the radius holds. --- components/monitor-feeds.tsx | 4 +++- public/r/ascii-fluid-svelte.json | 2 +- public/r/ascii-fluid.json | 2 +- public/r/shader-anime-fire-svelte.json | 2 +- public/r/shader-anime-fire.json | 2 +- public/r/shader-fire-svelte.json | 2 +- public/r/shader-fire.json | 2 +- public/r/shader-gradient-svelte.json | 2 +- public/r/shader-gradient.json | 2 +- public/r/shader-sky-svelte.json | 2 +- public/r/shader-sky.json | 2 +- registry/ascii-fluid/ascii-fluid.svelte | 7 +++++-- registry/ascii-fluid/ascii-fluid.tsx | 10 ++++++++-- registry/shader-anime-fire/shader-anime-fire.svelte | 5 ++++- registry/shader-anime-fire/shader-anime-fire.tsx | 12 ++++++++---- registry/shader-fire/shader-fire.svelte | 7 +++++-- registry/shader-fire/shader-fire.tsx | 10 ++++++++-- registry/shader-gradient/shader-gradient.svelte | 7 +++++-- registry/shader-gradient/shader-gradient.tsx | 10 ++++++++-- registry/shader-sky/shader-sky.svelte | 7 +++++-- registry/shader-sky/shader-sky.tsx | 10 ++++++++-- 21 files changed, 77 insertions(+), 32 deletions(-) diff --git a/components/monitor-feeds.tsx b/components/monitor-feeds.tsx index cf5ad23..db3e39a 100644 --- a/components/monitor-feeds.tsx +++ b/components/monitor-feeds.tsx @@ -43,7 +43,9 @@ const RadiantLines = feed(() => import("@/registry/radiant-lines/radiant-lines").then((m) => m.RadiantLines) ) const ShaderAnimeFire = feed(() => - import("@/registry/shader-anime-fire/shader-anime-fire").then((m) => m.ShaderAnimeFire) + import("@/registry/shader-anime-fire/shader-anime-fire").then( + (m) => m.ShaderAnimeFire + ) ) const ShaderFire = feed(() => import("@/registry/shader-fire/shader-fire").then((m) => m.ShaderFire) diff --git a/public/r/ascii-fluid-svelte.json b/public/r/ascii-fluid-svelte.json index e13ece7..ca5afd1 100644 --- a/public/r/ascii-fluid-svelte.json +++ b/public/r/ascii-fluid-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-fluid/ascii-fluid.svelte", - "content": "\n\n\n\n\n \n
\n", + "content": "\n\n\n\n\n \n \n