diff --git a/README.md b/README.md
index a94910b..39dfc09 100644
--- a/README.md
+++ b/README.md
@@ -72,17 +72,18 @@ pnpm dlx shadcn@latest add radiumcoders/23rd.dev/gooey-color-picker
## Components
-| Component | Install | Description |
-| ------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------- |
-| [ASCII Fluid](https://23rd.dev/docs/components/ascii-fluid) | `@23rd/ascii-fluid` | Mouse-trail WebGL fluid quantized to a clean ASCII brightness ramp |
-| [ASCII Logo](https://23rd.dev/docs/components/ascii-logo) | `@23rd/ascii-logo` | Interactive ASCII wordmark — hover shove, then click to scatter, drop, and gather |
-| [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex — joined by an SVG gooey filter |
-| [Logo Burst](https://23rd.dev/docs/components/logo-burst) | `@23rd/logo-burst` | Hair-line tentacles explode from the center, then breathe — light and dark |
-| [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 Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections |
-| [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back |
-| [Tangle Footer](https://23rd.dev/docs/components/tangle-footer) | `@23rd/tangle-footer` | Nested SVG text ribbons as a footer |
+| Component | Install | Description |
+| ------------------------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------- |
+| [ASCII Fluid](https://23rd.dev/docs/components/ascii-fluid) | `@23rd/ascii-fluid` | Mouse-trail WebGL fluid quantized to a clean ASCII brightness ramp |
+| [ASCII Logo](https://23rd.dev/docs/components/ascii-logo) | `@23rd/ascii-logo` | Interactive ASCII wordmark — hover shove, then click to scatter, drop, and gather |
+| [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex — joined by an SVG gooey filter |
+| [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 Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections |
+| [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back |
+| [Tangle Footer](https://23rd.dev/docs/components/tangle-footer) | `@23rd/tangle-footer` | Nested SVG text ribbons as a footer |
```tsx
import { GooeyColorPicker } from "@/components/ui/gooey-color-picker"
diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json
index 8cb6412..ad28742 100644
--- a/content/docs/components/meta.json
+++ b/content/docs/components/meta.json
@@ -3,6 +3,7 @@
"pages": [
"---Background---",
"logo-burst",
+ "phosphor-score",
"radiant-lines",
"ascii-fluid",
"---Shaders---",
diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx
new file mode 100644
index 0000000..34260ca
--- /dev/null
+++ b/content/docs/components/phosphor-score.mdx
@@ -0,0 +1,86 @@
+---
+title: Phosphor Score
+description: Vertical CRT sheet music — notes slide down the staves, bloom at the playhead, then exit in a flare.
+---
+
+import { PhosphorScoreDemo } from "@/registry/phosphor-score/phosphor-score-demo"
+
+A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, bloom in the phosphor color as they play, then flare out. 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. Press `d` to toggle.
+
+
+
+## Installation
+
+
+
+Or install straight from the public GitHub source registry:
+
+
+
+## Usage
+
+Give the parent a size. The canvas fills the parent.
+
+
+
+```tsx
+"use client"
+
+import { PhosphorScore } from "@/components/ui/phosphor-score"
+
+export function HeroScore() {
+ return (
+
+ )
+}
+```
+
+
+```svelte
+
+
+
+```
+
+
+
+Lock a theme or push the bloom. Omit `color` to follow light / dark.
+
+
+
+ ```tsx
+
+
+ ```
+
+
+ ```svelte
+
+
+ ```
+
+
+
+`prefers-reduced-motion: reduce` freezes the scroll and the camera drift, and keeps a still frame of the score.
+
+## Props
+
+| Prop | Type | Default |
+| ----------- | ----------------------------- | --------------------------------------- |
+| `color` | `string` | theme phosphor / forest ink |
+| `glow` | `number` | `50` — 0–100, halo size at the playhead |
+| `speed` | `number` | `1.35` — beats per second |
+| `density` | `number` | `1` — how packed the notation is |
+| `sway` | `boolean` | `true` — slow camera drift |
+| `seed` | `number` | `23` |
+| `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/content/docs/getting-started.mdx b/content/docs/getting-started.mdx
index b71a7e1..c680e44 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-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `radiant-lines`, `dithered-404`, `tangle-footer`, 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-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve.
## Use a background or shader
@@ -54,11 +54,11 @@ Shaders such as [Shader Gradient](/docs/components/shader-gradient) and [Shader
## Browse
-| Category | Components |
-| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
-| Background | [Logo Burst](/docs/components/logo-burst), [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) |
-| Footers | [Tangle Footer](/docs/components/tangle-footer), [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) |
+| 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) |
+| Footers | [Tangle Footer](/docs/components/tangle-footer), [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) |
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
index a697362..b481c89 100644
--- a/content/docs/index.mdx
+++ b/content/docs/index.mdx
@@ -22,5 +22,6 @@ Most component kits hand you every option. 23rd picks a direction: spacing, radi
- [Shader Gradient](/docs/components/shader-gradient) — quiet wash behind a landing hero
- [Shader Fire](/docs/components/shader-fire) — sparse fire tongues under a landing hero
- [Logo Burst](/docs/components/logo-burst) — hair-line tentacles explode from the center, then breathe
+- [Phosphor Score](/docs/components/phosphor-score) — vertical CRT sheet music that falls and flares
- [Radiant Lines](/docs/components/radiant-lines) — hyperspace starfield background
- [Dithered 404](/docs/components/dithered-404) — Bayer-pixel 404 that burns under a fireball cursor
diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json
new file mode 100644
index 0000000..582b854
--- /dev/null
+++ b/public/r/phosphor-score-svelte.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "phosphor-score-svelte",
+ "title": "Phosphor Score",
+ "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.",
+ "files": [
+ {
+ "path": "registry/phosphor-score/phosphor-score.svelte",
+ "content": "\n\n\n\n\n",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/phosphor-score.svelte"
+ }
+ ],
+ "type": "registry:ui"
+}
\ No newline at end of file
diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json
new file mode 100644
index 0000000..429840e
--- /dev/null
+++ b/public/r/phosphor-score.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "phosphor-score",
+ "title": "Phosphor Score",
+ "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.",
+ "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",
+ "type": "registry:ui",
+ "target": "components/ui/phosphor-score.tsx"
+ }
+ ],
+ "type": "registry:ui"
+}
\ No newline at end of file
diff --git a/public/r/registry.json b/public/r/registry.json
index 0a03c64..055e75d 100644
--- a/public/r/registry.json
+++ b/public/r/registry.json
@@ -163,6 +163,32 @@
],
"type": "registry:ui"
},
+ {
+ "name": "phosphor-score",
+ "title": "Phosphor Score",
+ "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.",
+ "files": [
+ {
+ "path": "registry/phosphor-score/phosphor-score.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/phosphor-score.tsx"
+ }
+ ],
+ "type": "registry:ui"
+ },
+ {
+ "name": "phosphor-score-svelte",
+ "title": "Phosphor Score",
+ "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.",
+ "files": [
+ {
+ "path": "registry/phosphor-score/phosphor-score.svelte",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/phosphor-score.svelte"
+ }
+ ],
+ "type": "registry:ui"
+ },
{
"name": "radiant-lines",
"title": "Radiant Lines",
diff --git a/registry.json b/registry.json
index 772b587..d865091 100644
--- a/registry.json
+++ b/registry.json
@@ -9,6 +9,7 @@
"registry/gooey-color-picker/registry.json",
"registry/live-orb/registry.json",
"registry/logo-burst/registry.json",
+ "registry/phosphor-score/registry.json",
"registry/radiant-lines/registry.json",
"registry/shader-fire/registry.json",
"registry/shader-gradient/registry.json",
diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx
new file mode 100644
index 0000000..1d4c9d8
--- /dev/null
+++ b/registry/phosphor-score/phosphor-score-demo.tsx
@@ -0,0 +1,128 @@
+"use client"
+
+import { useLayoutEffect, useMemo } from "react"
+
+import {
+ ComponentControls,
+ ControlColor,
+ ControlSlider,
+ ControlSwitch,
+} from "@/components/component-controls"
+import { ComponentPreview } from "@/components/component-preview"
+import { useHydratedTheme } from "@/hooks/use-hydrated-theme"
+import { usePreviewProps } from "@/hooks/use-preview-props"
+import {
+ DARK_COLOR,
+ DEFAULT_DENSITY,
+ DEFAULT_GLOW,
+ DEFAULT_SPEED,
+ LIGHT_COLOR,
+ PhosphorScore,
+} from "@/registry/phosphor-score/phosphor-score"
+
+function norm(hex: string) {
+ return hex.trim().toUpperCase()
+}
+
+function isStockColor(color: string) {
+ return norm(color) === norm(LIGHT_COLOR) || norm(color) === norm(DARK_COLOR)
+}
+
+export function PhosphorScoreDemo() {
+ const theme = useHydratedTheme()
+ const stock = theme === "dark" ? DARK_COLOR : LIGHT_COLOR
+
+ const defaults = useMemo(
+ () => ({
+ color: stock,
+ glow: DEFAULT_GLOW,
+ speed: DEFAULT_SPEED,
+ density: DEFAULT_DENSITY,
+ sway: true,
+ }),
+ [stock]
+ )
+
+ const { props, updateProp, resetProps, hasChanges, setProps } =
+ usePreviewProps(defaults)
+
+ useLayoutEffect(() => {
+ setProps((prev) => {
+ if (!isStockColor(prev.color)) return prev
+ if (norm(prev.color) === norm(stock)) return prev
+ return { ...prev, color: stock }
+ })
+ }, [setProps, stock])
+
+ const useAutoColor = isStockColor(props.color)
+
+ return (
+ <>
+
+
+
+
+
+ updateProp("color", v)}
+ />
+ updateProp("glow", v)}
+ />
+ updateProp("speed", v)}
+ />
+ updateProp("density", v)}
+ />
+ updateProp("sway", v)}
+ />
+
+ >
+ )
+}
diff --git a/registry/phosphor-score/phosphor-score-vanilla.ts b/registry/phosphor-score/phosphor-score-vanilla.ts
new file mode 100644
index 0000000..ec3fad0
--- /dev/null
+++ b/registry/phosphor-score/phosphor-score-vanilla.ts
@@ -0,0 +1,898 @@
+export type PhosphorScoreTheme = "light" | "dark" | "auto"
+
+export type PhosphorScoreOptions = {
+ /**
+ * Ink / phosphor color. Omit to follow `theme`
+ * (`LIGHT_COLOR` / `DARK_COLOR`).
+ */
+ color?: string
+ /**
+ * Bloom amount, 0–100. Default `50`. Scales the playhead halo
+ * and exit flare — kept small so it sits on the note, not a disc.
+ */
+ glow?: number
+ /** Scroll speed in beats per second. Default `1.35`. */
+ speed?: number
+ /** How packed the notation is. Default `1`. */
+ density?: number
+ /** Slow camera drift. Default `true`. */
+ sway?: boolean
+ /** Deterministic score. Default `23`. */
+ seed?: number
+ /**
+ * Palette mode. Default `auto` follows shadcn / next-themes
+ * (`html.dark` class).
+ */
+ theme?: PhosphorScoreTheme
+ /** Fires whenever resolved dark mode changes. */
+ onThemeChange?: (dark: boolean) => void
+}
+
+export type PhosphorScoreInstance = {
+ setOptions: (options: Partial) => void
+ destroy: () => void
+}
+
+/** Phosphor on black */
+export const DARK_COLOR = "#4DFF6A"
+/** Forest ink on the page background */
+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. */
+export const LIGHT_BG = "transparent"
+export const DEFAULT_GLOW = 50
+export const DEFAULT_SPEED = 1.35
+export const DEFAULT_DENSITY = 1
+export const DEFAULT_SEED = 23
+
+const LOOP_BEATS = 48
+const DYNAMICS = ["pp", "p", "mp", "mf", "f", "ff"] as const
+
+type Note = {
+ t: number
+ pitch: number
+ dur: number
+ staff: 0 | 1
+ beam: number
+ acc: -1 | 0 | 1
+}
+
+type Mark = {
+ t: number
+ staff: 0 | 1
+ text: string
+ pitch: number
+}
+
+type Hairpin = {
+ t: number
+ dur: number
+ staff: 0 | 1
+ open: boolean
+}
+
+type Slur = {
+ t0: number
+ t1: number
+ p0: number
+ p1: number
+ staff: 0 | 1
+}
+
+type Link = {
+ t: number
+ p0: number
+ p1: number
+}
+
+type Dust = {
+ x: number
+ y: number
+ z: number
+ s: number
+ a: number
+}
+
+type Flare = {
+ x: number
+ y: number
+ age: number
+ life: number
+ size: number
+}
+
+type Rgb = [number, number, number]
+
+function mulberry32(seed: number) {
+ let a = seed >>> 0
+ return () => {
+ a |= 0
+ a = (a + 0x6d2b79f5) | 0
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+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)
+}
+
+function speedOf(value?: number) {
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SPEED
+ return clamp(value, 0.15, 4)
+}
+
+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
+}
+
+function themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {
+ return value === "light" || value === "dark" || value === "auto"
+ ? value
+ : "auto"
+}
+
+function optionalColor(value?: string) {
+ const next = value?.trim()
+ return next ? next : undefined
+}
+
+/** 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: PhosphorScoreTheme): boolean {
+ if (theme === "dark") return true
+ if (theme === "light") return false
+ return isDarkTheme()
+}
+
+export function resolveColor(color: string | undefined, dark: boolean) {
+ const custom = optionalColor(color)
+ if (custom) return custom
+ return dark ? DARK_COLOR : LIGHT_COLOR
+}
+
+export function resolveBg(dark: boolean) {
+ return dark ? DARK_BG : LIGHT_BG
+}
+
+type Score = {
+ notes: Note[]
+ marks: Mark[]
+ hairpins: Hairpin[]
+ slurs: Slur[]
+ links: Link[]
+}
+
+function generateScore(seed: number, density: number): Score {
+ const rng = mulberry32(seed)
+ const notes: Note[] = []
+ 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({
+ t,
+ pitch: clamp(pitch, -3, 11),
+ dur,
+ staff,
+ beam,
+ acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,
+ })
+ }
+
+ const addRun = (
+ staff: 0 | 1,
+ start: number,
+ pitch: number,
+ count: number,
+ step: number,
+ dir: number
+ ) => {
+ const beam = group++
+ let p = pitch
+ 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 (count >= 4 && rng() < 0.45) {
+ slurs.push({
+ t0: start,
+ t1: start + (count - 1) * step,
+ p0: pitch,
+ p1: p,
+ staff,
+ })
+ }
+ }
+
+ 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 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)
+ }
+
+ let t = 0
+ while (t < LOOP_BEATS - 1) {
+ 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)
+
+ 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))
+ }
+ t += runLen * step
+ } 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)
+ }
+ 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 {
+ t += rng() < 0.5 ? 0.5 : 1
+ }
+
+ if (rng() < 0.18) {
+ 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,
+ })
+ }
+ }
+
+ return { notes, marks, hairpins, slurs, links }
+}
+
+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 dust
+}
+
+/**
+ * Vertical phosphor sheet music — notation falls toward a playhead,
+ * blooms, then exits in a flare. Canvas 2D, CRT-green by default.
+ */
+export function createPhosphorScore(
+ canvas: HTMLCanvasElement,
+ initial: PhosphorScoreOptions = {}
+): PhosphorScoreInstance | null {
+ let options: PhosphorScoreOptions = {
+ glow: DEFAULT_GLOW,
+ speed: DEFAULT_SPEED,
+ density: DEFAULT_DENSITY,
+ seed: DEFAULT_SEED,
+ ...initial,
+ sway: initial.sway ?? true,
+ theme: themeOf(initial.theme),
+ }
+
+ 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 dust = makeDust(seedOf(options.seed))
+ let dark = resolveDark(themeOf(options.theme))
+ let rgb = parseColor(resolveColor(options.color, dark))
+ options.onThemeChange?.(dark)
+ const flares: Flare[] = []
+ const hit = new Set()
+ let reduce = false
+ let raf = 0
+ let running = true
+ let lastFrame = performance.now()
+ let elapsed = 8
+ let clock = 0
+ let dtSmooth = 1 / 60
+
+ 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
+ size.w = w
+ size.h = h
+ canvas.width = Math.floor(w * dpr)
+ canvas.height = Math.floor(h * dpr)
+ canvas.style.width = `${w}px`
+ canvas.style.height = `${h}px`
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+ }
+
+ 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(themeOf(options.theme))
+ if (next === dark) return
+ dark = next
+ rgb = parseColor(resolveColor(options.color, dark))
+ 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 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
+ }
+
+ 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 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
+ }
+
+ 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()
+ }
+
+ 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()
+ }
+
+ 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
+ }
+ 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)
+ }
+
+ ctx.save()
+ ctx.globalAlpha = 1
+ 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)
+ }
+ ctx.restore()
+
+ const visMin = (playY - innerH / 2 / frameFit) / ppb
+ const visMax = (playY - topY) / ppb
+
+ const inView = (t: number) => {
+ const d = wrapDelta(t, beat, LOOP_BEATS)
+ return d < visMax && d > visMin
+ }
+
+ 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
+ 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()
+ }
+ }
+
+ ctx.lineCap = "round"
+ ctx.lineJoin = "round"
+
+ 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()
+ }
+ }
+
+ 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)
+ }
+
+ 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()
+ }
+
+ 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)
+ )
+ 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()
+ }
+
+ 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()
+ }
+
+ 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,
+ }
+ }
+
+ 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()
+ }
+ }
+
+ 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 (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()
+ }
+
+ 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))
+ }
+
+ 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
+ }
+ 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()
+ }
+ }
+ }
+
+ 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()
+ }
+
+ ctx.globalCompositeOperation = "source-over"
+
+ raf = requestAnimationFrame(frame)
+ }
+
+ raf = requestAnimationFrame(frame)
+
+ return {
+ setOptions(next) {
+ const prevSeed = seedOf(options.seed)
+ const prevDens = densityOf(options.density)
+ options = {
+ ...options,
+ ...next,
+ sway: next.sway ?? options.sway,
+ theme: themeOf(next.theme ?? options.theme),
+ }
+ syncTheme()
+ rgb = parseColor(resolveColor(options.color, dark))
+ if (
+ seedOf(options.seed) !== prevSeed ||
+ densityOf(options.density) !== prevDens
+ ) {
+ rebuild()
+ }
+ },
+ destroy() {
+ running = false
+ cancelAnimationFrame(raf)
+ ro.disconnect()
+ mo.disconnect()
+ mqReduce.removeEventListener("change", onReduce)
+ mqDark.removeEventListener("change", syncTheme)
+ },
+ }
+}
diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte
new file mode 100644
index 0000000..3dba72e
--- /dev/null
+++ b/registry/phosphor-score/phosphor-score.svelte
@@ -0,0 +1,119 @@
+
+
+
+
+
diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx
new file mode 100644
index 0000000..b5b86e9
--- /dev/null
+++ b/registry/phosphor-score/phosphor-score.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+
+import { cn } from "@/lib/utils"
+
+import {
+ createPhosphorScore,
+ DARK_BG,
+ DEFAULT_DENSITY,
+ DEFAULT_GLOW,
+ DEFAULT_SEED,
+ DEFAULT_SPEED,
+ resolveDark,
+ type PhosphorScoreInstance,
+ type PhosphorScoreOptions,
+} from "./phosphor-score-vanilla"
+
+export {
+ DARK_BG,
+ DARK_COLOR,
+ DEFAULT_COLOR,
+ DEFAULT_DENSITY,
+ DEFAULT_GLOW,
+ DEFAULT_SEED,
+ DEFAULT_SPEED,
+ LIGHT_BG,
+ LIGHT_COLOR,
+ resolveColor,
+ resolveDark,
+} from "./phosphor-score-vanilla"
+export type {
+ PhosphorScoreInstance,
+ PhosphorScoreOptions,
+ PhosphorScoreTheme,
+} from "./phosphor-score-vanilla"
+
+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 (
+ <>
+
+
+
+ >
+ )
+}
+
+/**
+ * Vertical phosphor sheet music — notes fall toward a playhead, bloom,
+ * then exit in a flare. Follows light and dark.
+ */
+export function PhosphorScore({
+ className,
+ color,
+ glow = DEFAULT_GLOW,
+ speed = DEFAULT_SPEED,
+ density = DEFAULT_DENSITY,
+ sway = true,
+ seed = DEFAULT_SEED,
+ theme = "auto",
+}: 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
+ if (!canvas) return
+ instanceRef.current = createPhosphorScore(canvas, {
+ color,
+ glow,
+ speed,
+ density,
+ sway,
+ seed,
+ 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({
+ color,
+ glow,
+ speed,
+ density,
+ sway,
+ seed,
+ theme,
+ onThemeChange: setIsDark,
+ })
+ }, [color, glow, speed, density, sway, seed, theme])
+
+ return (
+
+ )
+}
diff --git a/registry/phosphor-score/registry.json b/registry/phosphor-score/registry.json
new file mode 100644
index 0000000..6dcd757
--- /dev/null
+++ b/registry/phosphor-score/registry.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "items": [
+ {
+ "name": "phosphor-score",
+ "type": "registry:ui",
+ "title": "Phosphor Score",
+ "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.",
+ "files": [
+ {
+ "path": "phosphor-score.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/phosphor-score.tsx"
+ }
+ ]
+ },
+ {
+ "name": "phosphor-score-svelte",
+ "type": "registry:ui",
+ "title": "Phosphor Score",
+ "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.",
+ "files": [
+ {
+ "path": "phosphor-score.svelte",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/phosphor-score.svelte"
+ }
+ ]
+ }
+ ]
+}