From 23c509a1e328cdd90e294b5f262607503da56617 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 20:52:21 +0530 Subject: [PATCH 1/4] Drop the Svelte ports, so every registry item ships for React only. Removes the .svelte wrappers and their -svelte registry items, the Svelte compile test and dependency, and the docs framework picker. The vanilla engines stay; the build still inlines them into each React file. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/SKILL.md | 84 ++-- .cursor/skills/23rd/apis.md | 11 +- .cursor/skills/23rd/components.md | 12 +- .cursor/skills/23rd/conventions.md | 46 +- .cursor/skills/23rd/recipes.md | 6 - README.md | 29 +- app/docs/[[...slug]]/page.tsx | 1 - components/cli-command.tsx | 19 +- components/docs-shell.tsx | 35 +- components/framework-code.tsx | 50 --- components/framework-select.tsx | 54 --- components/landing.tsx | 4 +- components/mdx.tsx | 8 - content/docs/components/ascii-fluid.mdx | 19 +- content/docs/components/ascii-logo.mdx | 27 +- content/docs/components/dithered-404.mdx | 42 +- content/docs/components/folio.mdx | 41 +- .../docs/components/gooey-color-picker.mdx | 21 +- content/docs/components/iso-lattice.mdx | 39 +- content/docs/components/live-orb.mdx | 29 +- content/docs/components/logo-burst.mdx | 80 +--- content/docs/components/mech-keyboard.mdx | 41 +- content/docs/components/phosphor-score.mdx | 54 +-- content/docs/components/radiant-lines.mdx | 24 +- content/docs/components/ring-tower.mdx | 46 +- content/docs/components/shader-anime-fire.mdx | 48 +-- content/docs/components/shader-gradient.mdx | 40 +- content/docs/components/shader-metal.mdx | 35 +- content/docs/components/shader-sky.mdx | 92 +--- content/docs/components/stretchy-footer.mdx | 42 +- content/docs/components/typewriter.mdx | 36 +- content/docs/getting-started.mdx | 4 +- lib/framework.tsx | 102 ----- lib/seo.ts | 8 +- package.json | 1 - pnpm-lock.yaml | 83 ---- public/r/ascii-fluid-svelte.json | 15 - public/r/ascii-logo-svelte.json | 15 - public/r/dithered-404-svelte.json | 15 - public/r/folio-svelte.json | 15 - public/r/gooey-color-picker-svelte.json | 16 - public/r/iso-lattice-svelte.json | 15 - public/r/live-orb-svelte.json | 15 - public/r/logo-burst-svelte.json | 15 - public/r/mech-keyboard-svelte.json | 21 - public/r/mech-keyboard.json | 2 +- public/r/phosphor-score-svelte.json | 15 - public/r/radiant-lines-svelte.json | 16 - public/r/registry.json | 250 ----------- public/r/ring-tower-svelte.json | 21 - public/r/shader-anime-fire-svelte.json | 16 - public/r/shader-gradient-svelte.json | 15 - public/r/shader-metal-svelte.json | 15 - public/r/shader-sky-svelte.json | 15 - public/r/stretchy-footer-svelte.json | 16 - public/r/typewriter-svelte.json | 15 - registry/ascii-fluid/ascii-fluid.svelte | 83 ---- registry/ascii-fluid/registry.json | 13 - registry/ascii-logo/ascii-logo.svelte | 145 ------- registry/ascii-logo/registry.json | 13 - registry/dithered-404/dithered-404.svelte | 77 ---- registry/dithered-404/registry.json | 13 - registry/folio/folio.svelte | 107 ----- registry/folio/registry.json | 13 - .../gooey-color-picker.svelte | 393 ------------------ registry/gooey-color-picker/registry.json | 14 - registry/iso-lattice/iso-lattice.svelte | 85 ---- registry/iso-lattice/registry.json | 13 - registry/live-orb/live-orb.svelte | 120 ------ registry/live-orb/registry.json | 13 - registry/logo-burst/logo-burst.svelte | 150 ------- registry/logo-burst/registry.json | 13 - .../mech-keyboard/mech-keyboard-vanilla.ts | 2 +- registry/mech-keyboard/mech-keyboard.svelte | 160 ------- registry/mech-keyboard/registry.json | 19 - registry/phosphor-score/phosphor-score.svelte | 85 ---- registry/phosphor-score/registry.json | 13 - registry/radiant-lines/radiant-lines.svelte | 68 --- registry/radiant-lines/registry.json | 14 - registry/ring-tower/registry.json | 15 - registry/ring-tower/ring-tower.svelte | 73 ---- registry/shader-anime-fire/registry.json | 14 - .../shader-anime-fire.svelte | 112 ----- registry/shader-gradient/registry.json | 13 - .../shader-gradient/shader-gradient.svelte | 110 ----- registry/shader-metal/registry.json | 13 - registry/shader-metal/shader-metal.svelte | 77 ---- registry/shader-sky/registry.json | 13 - registry/shader-sky/shader-sky.svelte | 120 ------ registry/stretchy-footer/registry.json | 14 - .../stretchy-footer/stretchy-footer.svelte | 181 -------- registry/typewriter/registry.json | 13 - registry/typewriter/typewriter.svelte | 96 ----- scripts/registry-lib.mjs | 61 +-- skills/23rd/SKILL.md | 84 ++-- skills/23rd/apis.md | 11 +- skills/23rd/components.md | 12 +- skills/23rd/conventions.md | 46 +- skills/23rd/recipes.md | 6 - tests/listings.test.mjs | 5 +- tests/registry.test.mjs | 102 +---- tests/svelte.test.mjs | 38 -- 102 files changed, 207 insertions(+), 4499 deletions(-) delete mode 100644 components/framework-code.tsx delete mode 100644 components/framework-select.tsx delete mode 100644 lib/framework.tsx delete mode 100644 public/r/ascii-fluid-svelte.json delete mode 100644 public/r/ascii-logo-svelte.json delete mode 100644 public/r/dithered-404-svelte.json delete mode 100644 public/r/folio-svelte.json delete mode 100644 public/r/gooey-color-picker-svelte.json delete mode 100644 public/r/iso-lattice-svelte.json delete mode 100644 public/r/live-orb-svelte.json delete mode 100644 public/r/logo-burst-svelte.json delete mode 100644 public/r/mech-keyboard-svelte.json delete mode 100644 public/r/phosphor-score-svelte.json delete mode 100644 public/r/radiant-lines-svelte.json delete mode 100644 public/r/ring-tower-svelte.json delete mode 100644 public/r/shader-anime-fire-svelte.json delete mode 100644 public/r/shader-gradient-svelte.json delete mode 100644 public/r/shader-metal-svelte.json delete mode 100644 public/r/shader-sky-svelte.json delete mode 100644 public/r/stretchy-footer-svelte.json delete mode 100644 public/r/typewriter-svelte.json delete mode 100644 registry/ascii-fluid/ascii-fluid.svelte delete mode 100644 registry/ascii-logo/ascii-logo.svelte delete mode 100644 registry/dithered-404/dithered-404.svelte delete mode 100644 registry/folio/folio.svelte delete mode 100644 registry/gooey-color-picker/gooey-color-picker.svelte delete mode 100644 registry/iso-lattice/iso-lattice.svelte delete mode 100644 registry/live-orb/live-orb.svelte delete mode 100644 registry/logo-burst/logo-burst.svelte delete mode 100644 registry/mech-keyboard/mech-keyboard.svelte delete mode 100644 registry/phosphor-score/phosphor-score.svelte delete mode 100644 registry/radiant-lines/radiant-lines.svelte delete mode 100644 registry/ring-tower/ring-tower.svelte delete mode 100644 registry/shader-anime-fire/shader-anime-fire.svelte delete mode 100644 registry/shader-gradient/shader-gradient.svelte delete mode 100644 registry/shader-metal/shader-metal.svelte delete mode 100644 registry/shader-sky/shader-sky.svelte delete mode 100644 registry/stretchy-footer/stretchy-footer.svelte delete mode 100644 registry/typewriter/typewriter.svelte delete mode 100644 tests/svelte.test.mjs diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index 5f6abd6..d8d2283 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -1,8 +1,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 + Install, choose, and compose 23rd shadcn/ui registry components for React. + Use when the user wants a UI component, background, shader, WebGL wash, liquid metal or chrome, hero atmosphere, footer, 404, color picker, typewriter text, keyboard shortcut recorder, 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, @@ -14,7 +14,7 @@ metadata: # 23rd -23rd is a shadcn registry of opinionated components. Install with the shadcn CLI, then own the source. Every published item has a React build and a Svelte 5 build. This skill is the playbook. Open the linked files before inventing props. +23rd is a shadcn registry of opinionated components. Install with the shadcn CLI, then own the source. Every published item is a single React client component. This skill is the playbook. Open the linked files before inventing props. This folder is checked in twice and the copies must stay identical. `skills/23rd/` is the path the skills CLI and [skills.sh](https://skills.sh) discover. `.cursor/skills/23rd/` is what Cursor loads as `/23rd` in this repo. @@ -29,12 +29,10 @@ Do not invent props, CSS variables, or variants. If a prop is not in [apis.md](a ## Install -The consumer project must already be a shadcn project. React files import `cn` from `@/lib/utils` and land in `components/ui/`. Svelte files inline their own `cn`, use `class` (not `className`), and land in `src/lib/components/ui/`. React components are `"use client"`. +The consumer project must already be a shadcn project. React files import `cn` from `@/lib/utils` and land in `components/ui/`. React components are `"use client"`. Replace `` with the kebab-case item (`shader-gradient`, `gooey-color-picker`, …). -React: - ```bash pnpm dlx shadcn@latest add @23rd/ npx shadcn@latest add @23rd/ @@ -42,20 +40,10 @@ yarn dlx shadcn@latest add @23rd/ bunx --bun shadcn@latest add @23rd/ ``` -Svelte 5 (item suffix `-svelte`): - -```bash -pnpm dlx shadcn@latest add @23rd/-svelte -npx shadcn@latest add @23rd/-svelte -yarn dlx shadcn@latest add @23rd/-svelte -bunx --bun shadcn@latest add @23rd/-svelte -``` - GitHub source, if `@23rd` is not configured: ```bash pnpm dlx shadcn@latest add radiumcoders/23rd.dev/ -pnpm dlx shadcn@latest add radiumcoders/23rd.dev/-svelte ``` Imports after install: @@ -64,20 +52,14 @@ Imports after install: import { ShaderGradient } from "@/components/ui/shader-gradient" ``` -```svelte - -``` - npm dependencies the registry declares: -| Item | React | Svelte | -| --- | --- | --- | -| `gooey-color-picker` | `motion` | none | -| `ring-tower` | `three` | `three` | -| `mech-keyboard` | `three` | `three` | -| every other item | none | none | +| Item | Dependencies | +| --- | --- | +| `gooey-color-picker` | `motion` | +| `ring-tower` | `three` | +| `mech-keyboard` | `three` | +| every other item | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -148,7 +130,7 @@ Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `Logo `ShaderMetal` uses the same shell but is transparent: the ribbons and their shadows sit on the parent's background, so set one (`bg-background`). The ribbons cross the middle; put copy top-left. -`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. +`RadiantLines` is transparent and warps with scroll. Inside an overflow div, pass the scroller (`containerRef`) and make the canvas `sticky top-0 h-svh`. Omit the scroller to use the window. `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`). @@ -180,26 +162,26 @@ Exceptions: Categories match `content/docs/components/meta.json`. -| Name | Category | One line | Frameworks | -| --- | --- | --- | --- | -| `logo-burst` | Background | Hair-line tentacles explode from center, then breathe | React + Svelte | -| `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | React + Svelte | -| `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | -| `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | -| `iso-lattice` | Background | Isometric tiles build solid figures from the floor up | React + Svelte | -| `ring-tower` | Background | three.js tower of tilted image bands; drag to spin, neighbors counter-turn | React + Svelte | -| `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | -| `shader-anime-fire` | Shaders | Cel-shaded flames licking up from the bottom | React + Svelte | -| `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | -| `shader-metal` | Shaders | Chrome, gold, copper, or graphite ribbons; pointer key light | 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 | -| `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 real typewriter in SVG: keys press, typebars strike, the carriage steps | React + Svelte | -| `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | React + Svelte | +| Name | Category | One line | +| --- | --- | --- | +| `logo-burst` | Background | Hair-line tentacles explode from center, then breathe | +| `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | +| `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | +| `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | +| `iso-lattice` | Background | Isometric tiles build solid figures from the floor up | +| `ring-tower` | Background | three.js tower of tilted image bands; drag to spin, neighbors counter-turn | +| `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | +| `shader-anime-fire` | Shaders | Cel-shaded flames licking up from the bottom | +| `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | +| `shader-metal` | Shaders | Chrome, gold, copper, or graphite ribbons; pointer key light | +| `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | +| `live-orb` | Characters | Lit sphere; eyes follow the pointer | +| `ascii-logo` | Characters | ASCII wordmark: hover shove, click scatter / fall / gather | +| `dithered-404` | Pages | Bayer 404 burned by a fireball cursor, then reforms | +| `folio` | Sections | Page leans on scroll, then springs flat | +| `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | +| `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | +| `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | ## Minimal installs that must be right @@ -249,8 +231,8 @@ export function Accent() { } ``` -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). +Full prop tables are in [apis.md](apis.md). More assemblies are in [recipes.md](recipes.md). ## Adding a component -Only when the task is to add one to this repository. Follow [conventions.md](conventions.md). Short version: `registry//` with vanilla engine plus React and Svelte wrappers and a two-item `registry.json`, a doc at `content/docs/components/.mdx`, a row in `content/docs/components/meta.json`, then `pnpm registry:build` and `pnpm test`. +Only when the task is to add one to this repository. Follow [conventions.md](conventions.md). Short version: `registry//` with a vanilla engine, a React wrapper, and a `registry.json`, a doc at `content/docs/components/.mdx`, a row in `content/docs/components/meta.json`, then `pnpm registry:build` and `pnpm test`. diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 6674d77..5df5ae6 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -1,13 +1,6 @@ # Public APIs -Types below are what the React modules export. Svelte components take the same fields with these renames: - -| React | Svelte | -| --- | --- | -| `className` | `class` | -| `children?: ReactNode` | `children?: Snippet` | -| `scrollRef?: RefObject` | `scrollEl?: HTMLElement` | -| `containerRef?: RefObject` | `container?: HTMLElement` | +Types below are what the React modules export. Vanilla `onThemeChange` and `onHasGl` are not React props. The wrappers subscribe internally. `Dithered404` vanilla `onHideCursor` is also internal to the wrapper. @@ -415,7 +408,7 @@ type FolioPlayDetail = { } ``` -React exports `playFolioDemo`, `FOLIO_PLAY` (`"folio:play"`), `applyFolioFrame`. The Svelte component listens for that window event. Reduced motion: no tilt, no blur. +React exports `playFolioDemo`, `FOLIO_PLAY` (`"folio:play"`), `applyFolioFrame`. Reduced motion: no tilt, no blur. ## typewriter diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index eee2f84..a9b9260 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -1,6 +1,6 @@ # Component guide -React snippets are the primary examples. Svelte parity: same props, `class` instead of `className`, default import from `$lib/components/ui/.svelte`, `children` is a snippet, `scrollRef` is `scrollEl`, `containerRef` is `container`. Read [apis.md](apis.md) before adding a prop. +Read [apis.md](apis.md) before adding a prop. Shared shell for fill-the-parent pieces: @@ -46,7 +46,7 @@ Best fit: an infrastructure or developer-tool hero that wants one luminous objec Not this: hair-line explosion (`logo-burst`), scroll starfield (`radiant-lines`), images on a tower (`ring-tower`). -Deps: none (WebGL). Key props: `shapes` (`["cube", "steps", "pillars", "coil"]`), `interval` (4s), `color`, `theme` (`auto`), `size` (0.6), `spacing` (8px), `glow` (0.6), `speed` (1), `floor` (true), `interactive` (true). +Deps: none (WebGL). Key props: `shapes` (`["cube", "steps", "pillars", "coil"]`), `interval` (4s), `color`, `theme` (`auto`), `size` (0.6), `spacing` (8px), `speed` (1), `floor` (true), `interactive` (true). ```tsx "use client" @@ -99,7 +99,7 @@ Best fit: a long scrolling page that should feel like hyperspace. Not a static p Not this: `logo-burst` (one-shot explosion), `shader-gradient` (no scroll coupling). -Deps: none. Key props: `colors` (omit it and the palette follows the theme), `theme`, `starCount` (420), `displacement` (1), `containerRef` / Svelte `container`. +Deps: none. Key props: `colors` (omit it and the palette follows the theme), `theme`, `starCount` (420), `displacement` (1), `containerRef`. ```tsx "use client" @@ -282,7 +282,7 @@ Best fit: a Dia-like end of a long page. The component can be the scroller, or a Not this: tilting the page (`folio`). -Deps: none. Key props: `children`, `scrollRef` / Svelte `scrollEl`, `windowScroll` (false), `contentSelector` (`[data-stretchy-page]`), `maxStretch` (280), `colors`, `stiffness` (380), `damping` (32), `columns` (9), `blur` (16), `glow` (0), `flip` (false), `rotate` (0 or 180), `label`, `demoId`. +Deps: none. Key props: `children`, `scrollRef`, `windowScroll` (false), `contentSelector` (`[data-stretchy-page]`), `maxStretch` (280), `colors`, `stiffness` (380), `damping` (32), `columns` (9), `blur` (16), `glow` (0), `flip` (false), `rotate` (0 or 180), `label`, `demoId`. ```tsx "use client" @@ -417,7 +417,7 @@ Best fit: pick or edit one color inline. Not this: any background, shader, or theme switcher. -Deps: React needs `motion`. Svelte has none. Props: `value`, `defaultValue`, `onChange(color, css)`, `label` (`"Color picker"`), `className` / `class`. +Deps: `motion`. Props: `value`, `defaultValue`, `onChange(color, css)`, `label` (`"Color picker"`), `className`. ```tsx "use client" @@ -437,7 +437,7 @@ 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`. +Pitfalls: the panel overflows the trigger. Do not put it in `overflow-hidden`. Also exports `parseColor`. ## mech-keyboard diff --git a/.cursor/skills/23rd/conventions.md b/.cursor/skills/23rd/conventions.md index afb7dd0..e5a7275 100644 --- a/.cursor/skills/23rd/conventions.md +++ b/.cursor/skills/23rd/conventions.md @@ -8,9 +8,8 @@ Use this only when the task is to add or change a registry item in `radiumcoders registry// -vanilla.ts engine, no React .tsx "use client" wrapper - .svelte Svelte 5 wrapper -demo.tsx docs controls (optional; gooey inlines the component) - registry.json React item + -svelte item + registry.json one React item content/docs/components/.mdx ``` @@ -36,19 +35,6 @@ Match `registry/shader-gradient/registry.json`. "target": "components/ui/.tsx" } ] - }, - { - "name": "-svelte", - "type": "registry:ui", - "title": "Human Title", - "description": "Same sentence.", - "files": [ - { - "path": ".svelte", - "type": "registry:file", - "target": "src/lib/components/ui/.svelte" - } - ] } ] } @@ -56,12 +42,10 @@ Match `registry/shader-gradient/registry.json`. Rules tests enforce (`tests/registry.test.mjs`): -- Both items exist. Svelte name is `-svelte`. - `title` and `description` are non-empty strings. -- React `files[].type` is `registry:ui` and `target` is `components/ui/.tsx`. -- Svelte `files[].type` is `registry:file` and `target` is `src/lib/components/ui/.svelte` (`svelteTarget`). +- `files[].type` is `registry:ui` and `target` is `components/ui/.tsx`. - `dependencies` / `devDependencies` / `registryDependencies`, if present, are arrays of non-empty strings. -- Declare `motion` on the React item only when the wrapper imports `motion`. Svelte ports of those components currently declare no npm dependencies. +- Declare `motion` only when the wrapper imports `motion`. - Omit `dependencies` when there are none. Do not add a fake registry dependency. `scripts/build-registry.mjs` discovers `registry/*/registry.json`, rewrites the root `registry.json` `include` list, writes `.registry-standalone/` with vanilla code inlined, runs `shadcn registry validate` and `shadcn build` into `public/r/`. @@ -78,17 +62,9 @@ React: - Do not pass `onThemeChange` through. Watch theme inside the wrapper if the CSS fallback needs it. - Re-export public constants and types the docs name (`LIGHT_COLORS`, `GooeyColor`, …). -Svelte 5: - -- ` -``` - - ## Props @@ -154,7 +119,7 @@ import { Foo } from "@/components/ui/" Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components. Separators look like `"---Background---"`. -MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`, `FrameworkCode`, `FrameworkReact`, `FrameworkSvelte`. +MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`. Props tables must match the wrapper defaults. Do not copy a usage example into the Default column. @@ -174,7 +139,6 @@ pnpm typecheck ## Do not -- Publish a React-only item. Tests require a Svelte port. - Ship a component that only looks right in one theme. - Import `./-vanilla` from a file that is not the wrapper the build inlines. - Put site chrome (`components/docs-shell.tsx`, `components/ui/button.tsx`) into the registry. `components/ui/*` in this repo is the docs site’s shadcn kit, not the published set. diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index ee36002..bd81917 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -44,8 +44,6 @@ Swap the component, keep the shell: | Glowing isometric figure | `IsoLattice` | lattice in its own box under the copy | | Burst around a logo | `LogoBurst` | pass the mark as `children` | -Svelte: `import ShaderGradient from "$lib/components/ui/shader-gradient.svelte"` and `class` on the section if you style the component itself. - ## Scroll-linked starfield `radiant-lines` needs the element that actually scrolls. @@ -74,8 +72,6 @@ export function Story() { } ``` -Svelte: `bind:this={scroller}` and `container={scroller}`. - ## 404 Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. @@ -138,8 +134,6 @@ export function Document() { } ``` -Svelte uses `scrollEl` instead of `scrollRef`. - ## Page that leans ```tsx diff --git a/README.md b/README.md index d0a5ea8..312d190 100644 --- a/README.md +++ b/README.md @@ -43,22 +43,16 @@ Most kits hand you every option. 23rd picks a direction: spacing, motion, and in - **shadcn-native**: install with the CLI, own the source in your repo - **Opinionated defaults**: less boilerplate, clearer decisions -- **React and Svelte 5**: every published registry item ships for both; pick a framework on the install command +- **React-first**: every item is a single client component with typed props ## Quick start -Install a component (React): +Install a component: ```bash pnpm dlx shadcn@latest add @23rd/gooey-color-picker ``` -Svelte 5 (lands in `src/lib/components/ui/`): - -```bash -pnpm dlx shadcn@latest add @23rd/gooey-color-picker-svelte -``` - Or pull straight from GitHub: ```bash @@ -67,7 +61,7 @@ pnpm dlx shadcn@latest add radiumcoders/23rd.dev/gooey-color-picker ## Agent skill - IN BETA STAGE -The 23rd agent skill teaches a coding agent every published registry component: which one fits, how to install it for React or Svelte, and the props that actually exist. It also knows when not to use a component. +The 23rd agent skill teaches a coding agent every published registry component: which one fits, how to install it, and the props that actually exist. It also knows when not to use a component. [![skills.sh](https://skills.sh/b/radiumcoders/23rd.dev)](https://skills.sh/radiumcoders/23rd.dev) @@ -119,19 +113,6 @@ export function Example() { } ``` -Svelte: - -```svelte - - - console.log(color, css)} -/> -``` - ## Develop locally This repo is the docs site and registry source. @@ -151,7 +132,7 @@ Useful scripts: | `pnpm cf:deploy` | Build with OpenNext and deploy to Cloudflare Workers | | `pnpm cf:upload` | Build with OpenNext and upload a preview version | | `pnpm registry:build` | Emit `public/r/*.json` from `registry/` | -| `pnpm test` | Registry, Svelte compile, OG, and sponsors tests | +| `pnpm test` | Registry, listings, OG, and sponsors tests | | `pnpm typecheck` | MDX + TypeScript check | | `pnpm lint` | ESLint | @@ -232,7 +213,7 @@ Paste the matching `https://` payment link when prompted. Redeploy after changin ## Contributing -New components live under `registry//` with a vanilla engine, React/Svelte wrappers, and a `registry.json` (React item plus `-svelte`), then get documented in `content/docs/components/`. Run `pnpm registry:build` before shipping registry changes. +New components live under `registry//` with a vanilla engine, a React wrapper, and a `registry.json`, then get documented in `content/docs/components/`. Run `pnpm registry:build` before shipping registry changes. PRs that sharpen defaults, fix edge cases, or add tasteful components are welcome. diff --git a/app/docs/[[...slug]]/page.tsx b/app/docs/[[...slug]]/page.tsx index ec2b448..2a1cde0 100644 --- a/app/docs/[[...slug]]/page.tsx +++ b/app/docs/[[...slug]]/page.tsx @@ -132,7 +132,6 @@ export async function generateMetadata(props: { page.data.title, "shadcn component", "React component", - "Svelte component", ] : [page.data.title] diff --git a/components/cli-command.tsx b/components/cli-command.tsx index b5e3f71..e6ffc83 100644 --- a/components/cli-command.tsx +++ b/components/cli-command.tsx @@ -3,7 +3,6 @@ import { useSyncExternalStore } from "react" import { CopyButton } from "@/components/copy-button" -import { FrameworkSelect } from "@/components/framework-select" import { trackEvent } from "@/components/tracwell-analytics" import { BunIcon, @@ -19,7 +18,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { registryItemName, useFramework } from "@/lib/framework" import { cn } from "@/lib/utils" const STORAGE_KEY = "23rd:package-manager" @@ -98,8 +96,7 @@ export interface CliCommandProps { */ command?: string /** - * Registry item name without a framework suffix, e.g. `"shader-gradient"`. - * The active framework picker appends `-svelte` when Svelte is selected. + * Registry item name, e.g. `"shader-gradient"`. */ item?: string /** Install from GitHub (`radiumcoders/23rd.dev/`) instead of `@23rd/`. */ @@ -114,11 +111,10 @@ export interface CliCommandProps { className?: string } -function commandForItem(item: string, github: boolean, svelte: boolean) { - const name = registryItemName(item, svelte ? "svelte" : "react") +function commandForItem(item: string, github: boolean) { return github - ? `shadcn@latest add radiumcoders/23rd.dev/${name}` - : `shadcn@latest add @23rd/${name}` + ? `shadcn@latest add radiumcoders/23rd.dev/${item}` + : `shadcn@latest add @23rd/${item}` } export function CliCommand({ @@ -132,7 +128,6 @@ export function CliCommand({ defaultManager = "pnpm", className, }: CliCommandProps) { - const { framework } = useFramework() const manager = useSyncExternalStore( subscribeManager, () => readStoredManager() ?? defaultManager, @@ -140,8 +135,7 @@ export function CliCommand({ ) const resolvedCommand = - command ?? - (item ? commandForItem(item, github, framework === "svelte") : "") + command ?? (item ? commandForItem(item, github) : "") const fromCommand = resolvedCommand ? buildCommands(resolvedCommand) : null const commands: Record = { @@ -205,8 +199,6 @@ export function CliCommand({ - - {item ? : null}
@@ -220,7 +212,6 @@ export function CliCommand({ onCopied={() => trackEvent("cli_command_copied", { package_manager: manager, - framework, source: github ? "github" : "registry", ...(item ? { item } : {}), }) diff --git a/components/docs-shell.tsx b/components/docs-shell.tsx index 5403697..aa0ee91 100644 --- a/components/docs-shell.tsx +++ b/components/docs-shell.tsx @@ -17,7 +17,6 @@ import { Logo } from "@/components/logo" import { SearchTrigger } from "@/components/search-trigger" import { ThemeToggle } from "@/components/theme-toggle" import { FOOTER_LINKS, SUPPORT_EMAIL } from "@/lib/site" -import { FrameworkProvider } from "@/lib/framework" import { cn } from "@/lib/utils" import { Sidebar, @@ -270,23 +269,21 @@ export function DocsShell({ githubStars?: number | null }) { return ( - - - - -
- - -
- - -
- {children} -
-
-
+ + + +
+ + +
+ + +
+ {children} +
+
) } diff --git a/components/framework-code.tsx b/components/framework-code.tsx deleted file mode 100644 index 000fdac..0000000 --- a/components/framework-code.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import type { ReactNode } from "react" - -import { FrameworkSelect } from "@/components/framework-select" -import { cn } from "@/lib/utils" -import { useFramework } from "@/lib/framework" - -export function FrameworkReact({ children }: { children: ReactNode }) { - const { framework } = useFramework() - if (framework !== "react") return null - return children -} - -export function FrameworkSvelte({ children }: { children: ReactNode }) { - const { framework } = useFramework() - if (framework !== "svelte") return null - return children -} - -/** - * Shows the React or Svelte usage snippet based on the docs framework picker. - * The picker is the header row of the same plate as the code below it. - */ -export function FrameworkCode({ - children, - className, -}: { - children: ReactNode - className?: string -}) { - return ( -
-
- -
-
- {children} -
-
- ) -} - -export default FrameworkCode diff --git a/components/framework-select.tsx b/components/framework-select.tsx deleted file mode 100644 index 843933e..0000000 --- a/components/framework-select.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { FRAMEWORKS, useFramework, type Framework } from "@/lib/framework" - -const SELECT_ITEMS = FRAMEWORKS.map(({ value, label }) => ({ - value, - label, -})) - -export function FrameworkSelect({ className }: { className?: string }) { - const { framework, setFramework } = useFramework() - - function onChange(value: string | null) { - if (value === "react" || value === "svelte") { - setFramework(value) - } - } - - return ( - - ) -} diff --git a/components/landing.tsx b/components/landing.tsx index ba8de61..1bce809 100644 --- a/components/landing.tsx +++ b/components/landing.tsx @@ -54,8 +54,8 @@ function Hero() { Components with a pulse

- Shaders, backgrounds and interactive pieces for React and Svelte. Add - one with the shadcn CLI and own every line of it. + Shaders, backgrounds and interactive pieces for React. Add one with + the shadcn CLI and own every line of it.

- ```tsx "use client" @@ -35,20 +33,6 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
Your content
-
-``` -
- ## Props @@ -64,5 +48,4 @@ export function Hero() { | `animate` | `boolean` | `true` | | `interactive` | `boolean` | `true` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/ascii-logo.mdx b/content/docs/components/ascii-logo.mdx index a18876c..bc4ef79 100644 --- a/content/docs/components/ascii-logo.mdx +++ b/content/docs/components/ascii-logo.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Give the parent a size; the canvas fills it. The stage is transparent and the ink follows the theme. - - ```tsx "use client" @@ -34,34 +32,12 @@ export function FooterMark() { ) } ``` - - -```svelte - - -
- -
-``` -
-
`text` takes up to 5 letters. Pass `src` to sample an image instead (any raster, or a same-origin SVG). - - ```tsx ``` - - -```svelte - -``` - - Clicks cycle `logo → scattered → fallen → returning → logo`. @@ -95,5 +71,4 @@ Clicks cycle `logo → scattered → fallen → returning → logo`. | `theme` | `"light" \| "dark" \| "auto"` | `auto` | | `label` | `string` | `text` or `"ASCII logo"` | | `onPhaseChange` | `(phase) => void` | none | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/dithered-404.mdx b/content/docs/components/dithered-404.mdx index 938de57..2ce23e4 100644 --- a/content/docs/components/dithered-404.mdx +++ b/content/docs/components/dithered-404.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Pin it inside a relatively positioned section. - - ```tsx "use client" @@ -39,56 +37,19 @@ export function NotFound() { ) } ``` - - -```svelte - - -
- -
-

- This page could not be found. -

-
-
-``` -
-
Lock the theme: - - ```tsx ``` - - -```svelte - - -``` - - Soft fire instead of Bayer pixels: - - ```tsx ``` - - -```svelte - -``` - - ## Props @@ -100,5 +61,4 @@ Soft fire instead of Bayer pixels: | `interactive` | `boolean` | `true` | | `dither` | `boolean` | `true` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/folio.mdx b/content/docs/components/folio.mdx index c62541f..53d3ced 100644 --- a/content/docs/components/folio.mdx +++ b/content/docs/components/folio.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Use it as the scroll viewport; children are the page. - - ```tsx "use client" @@ -41,31 +39,9 @@ export function Page() { ) } ``` - - -```svelte - - - -
-

- The page leans back -

-

- Put the site in children. Folio is the scroller. -

-
-
-``` -
-
For window scroll, pass `windowScroll` and mark the page root with `data-folio-page`: - - ```tsx "use client" @@ -80,24 +56,12 @@ export function App() { ) } ``` - - -```svelte - - -
- -``` -
-
## Props | Prop | Type | Default | | --- | --- | --- | -| `children` | `ReactNode` / snippet | none | +| `children` | `ReactNode` | none | | `blur` | `number` | `4`, peak edge blur | | `perspective` | `number` | `1000`, floor `1000` | | `returnMs` | `number` | `520`, return time after the bottom | @@ -105,5 +69,4 @@ export function App() { | `contentSelector` | `string` | `[data-folio-page]` | | `label` | `string` | `Tilting page` | | `demoId` | `string` | pairs with `playFolioDemo({ target })` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/gooey-color-picker.mdx b/content/docs/components/gooey-color-picker.mdx index 5613f6e..7cfb344 100644 --- a/content/docs/components/gooey-color-picker.mdx +++ b/content/docs/components/gooey-color-picker.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: ## Usage - - ```tsx import { GooeyColorPicker } from "@/components/ui/gooey-color-picker" @@ -35,22 +33,6 @@ export function Example() { ) } ``` - - -```svelte - - - { - console.log(color, css) - }} -/> -``` - - ## Props @@ -60,7 +42,6 @@ export function Example() { | `defaultValue` | `GooeyColor \| string` | `{ h: 320, s: 90, l: 58, a: 1 }` | | `onChange` | `(color: GooeyColor, css: string) => void` | none | | `label` | `string` | `"Color picker"` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | `GooeyColor` is `{ h, s, l, a }` (h 0–360, s and l 0–100, a 0–1); strings accept hex or `hsl`/`hsla`. diff --git a/content/docs/components/iso-lattice.mdx b/content/docs/components/iso-lattice.mdx index e046b58..fbb80e3 100644 --- a/content/docs/components/iso-lattice.mdx +++ b/content/docs/components/iso-lattice.mdx @@ -21,8 +21,6 @@ Or from the public GitHub registry: Give the parent a height and put copy above the figure in `relative z-10`. The figure sits in the middle of the canvas, so a hero usually gives it the lower part of the section. - - ```tsx "use client" @@ -47,47 +45,13 @@ export function Hero() { ) } ``` - - -```svelte - - -
-
-

- Builds that assemble themselves -

-

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

-
-
- -
-
-``` -
-
Hold one figure, or pick the cycle: - - ```tsx ``` - - -```svelte - - -``` - - Under `prefers-reduced-motion: reduce` the first figure holds still with no build, glint or lamp. @@ -105,5 +69,4 @@ Under `prefers-reduced-motion: reduce` the first figure holds still with no buil | `floor` | `boolean` | `true` | | `interactive` | `boolean` | `true` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/live-orb.mdx b/content/docs/components/live-orb.mdx index 70b26ef..e7a0962 100644 --- a/content/docs/components/live-orb.mdx +++ b/content/docs/components/live-orb.mdx @@ -17,8 +17,6 @@ Or from the public GitHub registry: ## Usage - - ```tsx "use client" @@ -28,22 +26,9 @@ export function Mascot() { return } ``` - - -```svelte - - - -``` - - Without a `variant` the orb is white on dark pages and black on light ones. `custom` uses `color` and `eyeColor`; `webgl` takes an optional three-stop `colors`. - - ```tsx @@ -51,17 +36,6 @@ Without a `variant` the orb is white on dark pages and black on light ones. `cus ``` - - -```svelte - - - - - -``` - - ## Props @@ -75,5 +49,4 @@ Without a `variant` the orb is white on dark pages and black on light ones. `cus | `interactive` | `boolean` | `true` | | `blink` | `boolean` | `true` | | `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/logo-burst.mdx b/content/docs/components/logo-burst.mdx index 0eca5b0..d595561 100644 --- a/content/docs/components/logo-burst.mdx +++ b/content/docs/components/logo-burst.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Give the parent a size; the canvas is transparent. - - ```tsx "use client" @@ -34,43 +32,17 @@ export function HeroBurst() { ) } ``` - - -```svelte - - -
- -
-``` -
-
Pass children to put a mark in the center. - - - ```tsx - - - - ``` - - - ```svelte - - - - ``` - - +```tsx + + + +``` Replay from outside by incrementing `replayKey`. - - ```tsx "use client" @@ -80,41 +52,22 @@ import { LogoBurst } from "@/components/ui/logo-burst" export function Replayable() { const [key, setKey] = useState(0) -return ( - -
- - -
-) + return ( +
+ + +
+ ) } - -```` -
- -```svelte - - -
- - -
-```` - -
-
+``` ## Props | Prop | Type | Default | | --------------- | ----------------------------- | -------------------------------------------------------------- | -| `children` | `ReactNode` / `Snippet` | none | +| `children` | `ReactNode` | none | | `tentacleCount` | `number` | `260` | | `color` | `string` | theme ink / bone | | `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | @@ -127,5 +80,4 @@ Replay | `replayKey` | `number` | `0` | | `replayOnClick` | `boolean` | `true` | | `label` | `string` | `"Logo burst"` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/mech-keyboard.mdx b/content/docs/components/mech-keyboard.mdx index e25fb99..3926250 100644 --- a/content/docs/components/mech-keyboard.mdx +++ b/content/docs/components/mech-keyboard.mdx @@ -21,8 +21,6 @@ Or from the public GitHub registry: Give the parent a size; the keyboard fits itself inside it. - - ```tsx "use client" @@ -38,21 +36,6 @@ export function ShortcutStep() { ) } ``` - - -```svelte - - -
- console.log(shortcut.label)} - /> -
-``` -
-
`onShortcut` fires when every key of a chord is released. It gets `codes` (`KeyboardEvent.code`s, modifiers first), `keys` (one legend per key), and `label` (`"⌘⇧K"` on Mac, `"Ctrl+Shift+K"` elsewhere). @@ -60,22 +43,11 @@ Keys listen on the window, but typing in an input, textarea, or contenteditable Needs WebGL; without it the canvas stays empty and the recorder still works. The pose is fixed at true isometric. Move it, drop the color, or pulse the keys you want pressed: - - - ```tsx - - - - ``` - - - ```svelte - - - - ``` - - +```tsx + + + +``` `monochrome` swaps the blue caps for white and grey, leaving a pure ink drawing. The recorder draws each key of the chord as a small keycap in the same style; held keys sink into their skirts. The knob turns under the mouse wheel and with the volume keys, and clicking it records Mute. @@ -96,5 +68,4 @@ Needs WebGL; without it the canvas stays empty and the recorder still works. The | `intro` | `boolean` | `true` | | `onKey` | `(code: string, pressed: boolean) => void` | none | | `onShortcut` | `(shortcut: MechKeyboardShortcut) => void` | none | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx index 6017039..ac636be 100644 --- a/content/docs/components/phosphor-score.mdx +++ b/content/docs/components/phosphor-score.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Give the parent a size; the transparent canvas fills it. - - ```tsx "use client" @@ -34,53 +32,20 @@ export function HeroScore() { ) } ``` - - -```svelte - - -
- -
-``` -
-
Lock a theme or set the glow: - - - ```tsx - - - ``` - - - ```svelte - - - ``` - - +```tsx + + +``` Tilt the plane in perspective, like CSS rotations: - - - ```tsx - - - ``` - - - ```svelte - - - ``` - - +```tsx + + +``` ## Props @@ -96,5 +61,4 @@ Tilt the plane in perspective, like CSS rotations: | `rotateY` | `number` | `0`, degrees, `-70`–`70` | | `rotateZ` | `number` | `0`, degrees | | `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/radiant-lines.mdx b/content/docs/components/radiant-lines.mdx index eb3e055..e157804 100644 --- a/content/docs/components/radiant-lines.mdx +++ b/content/docs/components/radiant-lines.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Pass the scroll container if the page scrolls inside an element, not the window. - - ```tsx "use client" @@ -38,22 +36,6 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
Your content
-
-``` -
-
## Props @@ -63,7 +45,5 @@ export function Hero() { | `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | | `starCount` | `number` | `420` | | `displacement` | `number` | `1`, star jump on warp | -| `containerRef` | `RefObject` | React: window scroll | -| `container` | `HTMLElement` | Svelte: window scroll | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `containerRef` | `RefObject` | window scroll | +| `className` | `string` | none | diff --git a/content/docs/components/ring-tower.mdx b/content/docs/components/ring-tower.mdx index 3cced8d..bf031a5 100644 --- a/content/docs/components/ring-tower.mdx +++ b/content/docs/components/ring-tower.mdx @@ -21,8 +21,6 @@ Installs `three` alongside the component. Pin it inside a relatively positioned section with a height. The canvas is transparent, so give the section a background. The inside of the bands fades toward black in dark mode and white in light mode, following the page unless you set `theme`. Each band leans a quarter turn from the last, so the stack zig-zags past the edges of the frame. - - ```tsx "use client" @@ -39,46 +37,19 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
-
-``` -
-
Images are drawn side by side at one height and repeated in whole tiles around each band, then cropped top and bottom to keep their proportions. Images on another origin need CORS headers, or they are skipped. With no images, or if none load, the bands show a plain green grid. Every band in step, like the original: - - ```tsx ``` - - -```svelte - -``` - - ### Each ring Pass `rings` an array instead of a number to set each ring on its own, bottom ring first. The array's length is the ring count, up to 5. Anything a ring leaves out follows the tower, so `{}` is a plain ring. - - ```tsx ``` - - -```svelte - -``` - - | Ring field | Type | Default | | --- | --- | --- | @@ -124,5 +81,4 @@ Pass `rings` an array instead of a number to set each ring on its own, bottom ri | `parallax` | `number` | `24`, degrees, 0–90 | | `backface` | `number` | `0.86`, inside fade, 0–1 | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/shader-anime-fire.mdx b/content/docs/components/shader-anime-fire.mdx index 29e9282..a52ed00 100644 --- a/content/docs/components/shader-anime-fire.mdx +++ b/content/docs/components/shader-anime-fire.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Pin it behind a relatively positioned section, with content in `relative z-10`. - - ```tsx "use client" @@ -45,62 +43,19 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
-

- New -

-

- Ship something that catches -

-

- Flames along the floor. The copy stays clear. -

-
-
-``` -
-
Lock the theme: - - ```tsx ``` - - -```svelte - - -``` - - Print it as Bayer pixels: - - ```tsx ``` - - -```svelte - -``` - - ## Props @@ -114,5 +69,4 @@ Print it as Bayer pixels: | `dither` | `boolean` | `false` | | `pixelSize` | `number` | `1` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/shader-gradient.mdx b/content/docs/components/shader-gradient.mdx index 02e3728..df1ea96 100644 --- a/content/docs/components/shader-gradient.mdx +++ b/content/docs/components/shader-gradient.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Pin it behind a relatively positioned section, with content in `relative z-10`. - - ```tsx "use client" @@ -46,48 +44,13 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
-

- New -

-

- Ship the page, not the backdrop -

-

- Headline, proof, and a primary action sit on the wash. The shader - stays quiet. -

-
-
-``` -
-
Lock the theme: - - ```tsx ``` - - -```svelte - - -``` - - ## Props @@ -100,5 +63,4 @@ Lock the theme: | `grain` | `number` | `0.35`, 0–1, `0` for none | | `interactive` | `boolean` | `true` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/shader-metal.mdx b/content/docs/components/shader-metal.mdx index d8e6c4b..813fe0d 100644 --- a/content/docs/components/shader-metal.mdx +++ b/content/docs/components/shader-metal.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Pin it behind a relatively positioned section. The ribbons cross the middle, so copy sits best top-left. - - ```tsx "use client" @@ -42,42 +40,12 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
-

- Polished until it reflects the room -

-

- The ribbons stay in the back. The key light follows the pointer. -

-
-
-``` -
-
Custom tint with more sheen: - - ```tsx ``` - - -```svelte - -``` - - ## Props @@ -90,5 +58,4 @@ Custom tint with more sheen: | `iridescence` | `number` | `0.2`, 0–1 sheen | | `interactive` | `boolean` | `true` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/shader-sky.mdx b/content/docs/components/shader-sky.mdx index 3f3b009..54e667b 100644 --- a/content/docs/components/shader-sky.mdx +++ b/content/docs/components/shader-sky.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Pin it behind a relatively positioned section, with content in `relative z-10`. - - ```tsx "use client" @@ -45,92 +43,31 @@ export function Hero() { ) } ``` - - -```svelte - - -
- -
-

- New -

-

- The sky is the theme -

-

- Clear in light. Rain in dark. The shader stays behind the copy. -

-
-
-``` -
-
Lock the weather: - - - ```tsx - - - ``` - - - ```svelte - - - ``` - - +```tsx + + +``` Window glass, a dotted film over the sky: - - - ```tsx - - ``` - - - ```svelte - - ``` - - +```tsx + +``` Lightning flashes inside the clouds: - - - ```tsx - - ``` - - - ```svelte - - ``` - - +```tsx + +``` Fewer, smaller clouds: - - - ```tsx - - ``` - - - ```svelte - - ``` - - +```tsx + +``` ## Props @@ -148,5 +85,4 @@ Fewer, smaller clouds: | `glass` | `boolean` | `false` | | `glassSize` | `number` | `7` | | `theme` | `"light" \| "dark" \| "auto"` | `auto` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/stretchy-footer.mdx b/content/docs/components/stretchy-footer.mdx index d391529..1bb644b 100644 --- a/content/docs/components/stretchy-footer.mdx +++ b/content/docs/components/stretchy-footer.mdx @@ -19,8 +19,6 @@ Or from the public GitHub registry: Use it as the scroll viewport, with your page and footer as children. - - ```tsx "use client" @@ -39,29 +37,9 @@ export function Page() { ) } ``` - - -```svelte - - - -
- -
-
- 23rd · opinionated UI for shippers -
-
-``` -
-
For window scroll, pass `windowScroll` and mark the page root with `data-stretchy-page`: - - ```tsx "use client" @@ -76,26 +54,13 @@ export function DocsChrome() { ) } ``` - - -```svelte - - -
- -``` -
-
## Props | Prop | Type | Default | | ----------------- | -------------------------------- | ------------------------------------------------- | -| `children` | `ReactNode` / snippet | none | -| `scrollRef` | `RefObject` | React: self as scroller | -| `scrollEl` | `HTMLElement` | Svelte: self as scroller | +| `children` | `ReactNode` | none | +| `scrollRef` | `RefObject` | self as scroller | | `windowScroll` | `boolean` | `false` | | `contentSelector` | `string` | `[data-stretchy-page]` | | `maxStretch` | `number` | `280` | @@ -109,5 +74,4 @@ export function DocsChrome() { | `rotate` | `0 \| 180` | `0`, `180` hangs it from the page | | `label` | `string` | `Stretchy overflow` | | `demoId` | `string` | pairs with `playStretchyFooterDemo({ target })` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/components/typewriter.mdx b/content/docs/components/typewriter.mdx index bb7de87..71f0fcf 100644 --- a/content/docs/components/typewriter.mdx +++ b/content/docs/components/typewriter.mdx @@ -21,8 +21,6 @@ Or from the public GitHub registry: Give the parent a size; the machine scales to fit. - - ```tsx "use client" @@ -36,36 +34,13 @@ export function Letter() { ) } ``` - - -```svelte - - -
- -
-``` -
-
`\n` breaks a line; text wraps at `columns` characters. - - - ```tsx - console.log("typed")} /> - - ``` - - - ```svelte - console.log("typed")} /> - - ``` - - +```tsx + console.log("typed")} /> + +``` `sound` only plays after the visitor has clicked or pressed a key on the page. @@ -86,5 +61,4 @@ export function Letter() { | `interactive` | `boolean` | `true` | | `label` | `string` | none, nameplate text | | `onDone` | `() => void` | fires when the last character lands | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| `className` | `string` | none | diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index 7c40490..bcd216f 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -3,7 +3,7 @@ title: Getting Started description: Install 23rd components into a shadcn project with the CLI. --- -23rd is a [shadcn/ui](https://ui.shadcn.com) registry for React and Svelte 5. Pick your framework on the install command. +23rd is a [shadcn/ui](https://ui.shadcn.com) registry of React components. ## Install a component @@ -13,7 +13,7 @@ Or from the public GitHub registry: -Swap `shader-gradient` for any item name. Svelte items are `@23rd/-svelte` and land in `src/lib/components/ui/`. +Swap `shader-gradient` for any item name. ## Use a background or shader diff --git a/lib/framework.tsx b/lib/framework.tsx deleted file mode 100644 index 6fa0114..0000000 --- a/lib/framework.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client" - -import { - createContext, - useCallback, - useContext, - useMemo, - useSyncExternalStore, - type ReactNode, -} from "react" - -export type Framework = "react" | "svelte" - -const STORAGE_KEY = "23rd:framework" -const EVENT = "23rd:framework-change" - -const FRAMEWORKS = [ - { value: "react" as const, label: "React" }, - { value: "svelte" as const, label: "Svelte" }, -] - -function isFramework(value: string): value is Framework { - return value === "react" || value === "svelte" -} - -function readStoredFramework(): Framework | null { - try { - const stored = window.localStorage.getItem(STORAGE_KEY) - if (stored && isFramework(stored)) return stored - } catch { - // ignore - } - return null -} - -function writeStoredFramework(value: Framework) { - try { - window.localStorage.setItem(STORAGE_KEY, value) - } catch { - // ignore - } - window.dispatchEvent(new Event(EVENT)) -} - -function subscribeFramework(onStoreChange: () => void) { - window.addEventListener(EVENT, onStoreChange) - window.addEventListener("storage", onStoreChange) - return () => { - window.removeEventListener(EVENT, onStoreChange) - window.removeEventListener("storage", onStoreChange) - } -} - -type FrameworkContextValue = { - framework: Framework - setFramework: (value: Framework) => void -} - -const FrameworkContext = createContext(null) - -export function FrameworkProvider({ - children, - defaultFramework = "react", -}: { - children: ReactNode - defaultFramework?: Framework -}) { - const framework = useSyncExternalStore( - subscribeFramework, - () => readStoredFramework() ?? defaultFramework, - () => defaultFramework - ) - - const setFramework = useCallback((value: Framework) => { - writeStoredFramework(value) - }, []) - - const value = useMemo( - () => ({ framework, setFramework }), - [framework, setFramework] - ) - - return ( - - {children} - - ) -} - -export function useFramework() { - const ctx = useContext(FrameworkContext) - if (!ctx) { - throw new Error("useFramework must be used within FrameworkProvider") - } - return ctx -} - -export function registryItemName(item: string, framework: Framework) { - return framework === "svelte" ? `${item}-svelte` : item -} - -export { FRAMEWORKS, isFramework } diff --git a/lib/seo.ts b/lib/seo.ts index c0833d9..7db1ef2 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -7,7 +7,7 @@ export const SITE_NAME = "23rd" export const SITE_TAGLINE = "Opinionated UI components for shippers" export const SITE_TITLE = `${SITE_NAME}: ${SITE_TAGLINE}` export const SITE_DESCRIPTION = - "A shadcn/ui registry of opinionated React and Svelte components: shaders, backgrounds, footers, and interactive UI. Install with the CLI, own the source, and ship." + "A shadcn/ui registry of opinionated React components: shaders, backgrounds, footers, and interactive UI. Install with the CLI, own the source, and ship." export const SITE_KEYWORDS = [ "23rd", @@ -15,10 +15,8 @@ export const SITE_KEYWORDS = [ "shadcn registry", "shadcn/ui", "React components", - "Svelte components", "UI components", "Next.js", - "Svelte", "Tailwind CSS", "WebGL", "shaders", @@ -219,8 +217,8 @@ export function docsJsonLd({ description: desc, url, codeRepository: getGithubRepoUrl(), - programmingLanguage: ["TypeScript", "React", "Svelte"], - runtimePlatform: ["React", "Svelte"], + programmingLanguage: ["TypeScript", "React"], + runtimePlatform: ["React"], isPartOf: { "@id": `${SITE_URL}/#app` }, }) } diff --git a/package.json b/package.json index 97d5099..3e73fba 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "eslint-config-next": "16.3.8", "prettier": "^3.9.6", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.2", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "wrangler": "^4.119.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f0fcbc..7030678 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,9 +99,6 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.8.1 version: 0.8.1(prettier@3.9.6) - svelte: - specifier: ^5.57.2 - version: 5.57.2(@typescript-eslint/types@8.65.0) tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -1731,11 +1728,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@sveltejs/acorn-typescript@1.0.13': - resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} - peerDependencies: - acorn: ^8.9.0 - '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -2226,10 +2218,6 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.3.1: - resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} - engines: {node: '>= 0.4'} - aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2634,9 +2622,6 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - devalue@5.9.4: - resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} - devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2877,9 +2862,6 @@ packages: jiti: optional: true - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2893,14 +2875,6 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} - esrap@2.4.0: - resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==} - peerDependencies: - '@typescript-eslint/types': ^8.2.0 - peerDependenciesMeta: - '@typescript-eslint/types': - optional: true - esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -3537,9 +3511,6 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3777,9 +3748,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -4853,10 +4821,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svelte@5.57.2: - resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==} - engines: {node: '>=18'} - systeminformation@5.33.1: resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==} engines: {node: '>=10.0.0'} @@ -5215,9 +5179,6 @@ packages: yuku-ast@0.6.11: resolution: {integrity: sha512-ZfXkFYVsDewS45+kv3WiA/qNB73CRfxFDEQwfnRMUAR4AD5zRI7PRqxmI2U3Jz/oG41GneTVW6mxDOQal0lgeA==} - zimmerframe@1.1.5: - resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -7067,10 +7028,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)': - dependencies: - acorn: 8.19.0 - '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -7486,8 +7443,6 @@ snapshots: argparse@2.0.1: {} - aria-query@5.3.1: {} - aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -7887,8 +7842,6 @@ snapshots: detect-node-es@1.1.0: {} - devalue@5.9.4: {} - devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -8329,8 +8282,6 @@ snapshots: transitivePeerDependencies: - supports-color - esm-env@1.2.2: {} - espree@10.4.0: dependencies: acorn: 8.17.0 @@ -8343,12 +8294,6 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.4.0(@typescript-eslint/types@8.65.0): - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - optionalDependencies: - '@typescript-eslint/types': 8.65.0 - esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -9063,10 +9008,6 @@ snapshots: is-promise@4.0.0: {} - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.9 - is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9258,8 +9199,6 @@ snapshots: lines-and-columns@1.2.4: {} - locate-character@3.0.0: {} - locate-path@3.0.0: dependencies: p-locate: 3.0.0 @@ -10732,26 +10671,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte@5.57.2(@typescript-eslint/types@8.65.0): - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.6.0 - '@sveltejs/acorn-typescript': 1.0.13(acorn@8.19.0) - '@types/estree': 1.0.9 - acorn: 8.19.0 - aria-query: 5.3.1 - axobject-query: 4.1.0 - clsx: 2.1.1 - devalue: 5.9.4 - esm-env: 1.2.2 - esrap: 2.4.0(@typescript-eslint/types@8.65.0) - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.21 - zimmerframe: 1.1.5 - transitivePeerDependencies: - - '@typescript-eslint/types' - systeminformation@5.33.1: {} tailwind-merge@3.6.0: {} @@ -11182,8 +11101,6 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.6.8 - zimmerframe@1.1.5: {} - zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/public/r/ascii-fluid-svelte.json b/public/r/ascii-fluid-svelte.json deleted file mode 100644 index 0a82e8b..0000000 --- a/public/r/ascii-fluid-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "ascii-fluid-svelte", - "title": "ASCII Fluid", - "description": "Mouse-trail WebGL fluid background quantized to a clean ASCII brightness ramp - zero deps, theme-aware.", - "files": [ - { - "path": "registry/ascii-fluid/ascii-fluid.svelte", - "content": "\n\n\n\n\n \n \n
\n", - "type": "registry:file", - "target": "src/lib/components/ui/ascii-fluid.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json deleted file mode 100644 index d8d953b..0000000 --- a/public/r/ascii-logo-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "ascii-logo-svelte", - "title": "ASCII Logo", - "description": "Interactive ASCII wordmark: glyphs shove away from the cursor, then click-cycle through scatter, gravity drop, and reassemble. Zero deps.", - "files": [ - { - "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n
\n", - "type": "registry:file", - "target": "src/lib/components/ui/ascii-logo.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/dithered-404-svelte.json b/public/r/dithered-404-svelte.json deleted file mode 100644 index 5f55ccf..0000000 --- a/public/r/dithered-404-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "dithered-404-svelte", - "title": "Dithered 404", - "description": "Bayer-pixel 404 section: a fireball cursor scorches the glyph, burns it into embers and smoke, then reforms. Optional soft realistic fire.", - "files": [ - { - "path": "registry/dithered-404/dithered-404.svelte", - "content": "\n\n\n\n\n 404\n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/dithered-404.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/folio-svelte.json b/public/r/folio-svelte.json deleted file mode 100644 index 7effe99..0000000 --- a/public/r/folio-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "folio-svelte", - "title": "Folio", - "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.", - "files": [ - { - "path": "registry/folio/folio.svelte", - "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" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/gooey-color-picker-svelte.json b/public/r/gooey-color-picker-svelte.json deleted file mode 100644 index 94333c7..0000000 --- a/public/r/gooey-color-picker-svelte.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "gooey-color-picker-svelte", - "title": "Gooey Color Picker", - "description": "A floating color picker with a circular hue wheel, alpha slider, hex field, and motion-powered open/close.", - "dependencies": [], - "files": [ - { - "path": "registry/gooey-color-picker/gooey-color-picker.svelte", - "content": "\n\n\n\n\n \n updateWheelFromPointer(x, y))}\n onkeydown={onWheelKeyDown}\n >\n \n \n\n updateAlphaFromPointer(x))}\n onkeydown={onAlphaKeyDown}\n >\n \n \n\n \n \n {\n hexFocused = true\n }}\n onblur={() => {\n hexFocused = false\n commitHex(hexDraft)\n }}\n onkeydown={onHexKeyDown}\n class=\"min-w-0 flex-1 bg-transparent font-mono text-sm text-popover-foreground outline-none placeholder:text-muted-foreground\"\n />\n {#if supportsEyeDropper}\n \n \n \n \n \n \n \n {/if}\n \n \n\n {\n open = !open\n }}\n >\n \n {#if open}\n {#each CLOSE_PATHS as d}\n \n {/each}\n {:else}\n {#each PALETTE_PATHS as d}\n \n {/each}\n {/if}\n \n \n\n\n\n", - "type": "registry:file", - "target": "src/lib/components/ui/gooey-color-picker.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/iso-lattice-svelte.json b/public/r/iso-lattice-svelte.json deleted file mode 100644 index 05d28b3b..0000000 --- a/public/r/iso-lattice-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "iso-lattice-svelte", - "title": "Iso Lattice", - "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", - "files": [ - { - "path": "registry/iso-lattice/iso-lattice.svelte", - "content": "\n\n\n\n\n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/iso-lattice.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/live-orb-svelte.json b/public/r/live-orb-svelte.json deleted file mode 100644 index 653630b..0000000 --- a/public/r/live-orb-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "live-orb-svelte", - "title": "Live Orb", - "description": "An evenly lit sphere with two capsule eyes that follow the pointer: white, black, webgl, and custom variants.", - "files": [ - { - "path": "registry/live-orb/live-orb.svelte", - "content": "\n\n\n\n\n {#if !hasGl}\n \n \n \n \n {/if}\n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/live-orb.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/logo-burst-svelte.json b/public/r/logo-burst-svelte.json deleted file mode 100644 index ebc1850..0000000 --- a/public/r/logo-burst-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "logo-burst-svelte", - "title": "Logo Burst", - "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", - "files": [ - { - "path": "registry/logo-burst/logo-burst.svelte", - "content": "\n\n\n\n\n \n {#if children}\n \n
\n {@render children()}\n
\n \n {/if}\n {#if replayOnClick}\n \n {/if}\n\n", - "type": "registry:file", - "target": "src/lib/components/ui/logo-burst.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/mech-keyboard-svelte.json b/public/r/mech-keyboard-svelte.json deleted file mode 100644 index 5b43a0f..0000000 --- a/public/r/mech-keyboard-svelte.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "mech-keyboard-svelte", - "title": "Mech Keyboard", - "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", - "dependencies": [ - "three" - ], - "devDependencies": [ - "@types/three" - ], - "files": [ - { - "path": "registry/mech-keyboard/mech-keyboard.svelte", - "content": "\n\n\n\n\n
\n {#if recorder}\n
\n
\n {#if shortcut.keys.length === 0}\n {placeholder}\n {:else}\n {#each shortcut.keys as key, i (shortcut.codes[i])}\n {#if i > 0}+{/if}\n \n {key}\n \n {/each}\n {/if}\n
\n
\n {#if phase === \"done\"}\n \n \n \n Shortcut saved\n {:else if phase === \"live\"}\n \n Recording\n {:else}\n Listening\n {/if}\n
\n
\n {/if}\n\n", - "type": "registry:file", - "target": "src/lib/components/ui/mech-keyboard.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/mech-keyboard.json b/public/r/mech-keyboard.json index 4ec8326..faffb73 100644 --- a/public/r/mech-keyboard.json +++ b/public/r/mech-keyboard.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/mech-keyboard/mech-keyboard.tsx", - "content": "\"use client\"\n\nimport {\n Fragment,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n} from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n AmbientLight,\n BoxGeometry,\n BufferGeometry,\n CanvasTexture,\n Color,\n DataTexture,\n DepthTexture,\n DirectionalLight,\n ExtrudeGeometry,\n Float32BufferAttribute,\n Group,\n HalfFloatType,\n LatheGeometry,\n Mesh,\n MeshBasicMaterial,\n MeshNormalMaterial,\n MeshToonMaterial,\n NearestFilter,\n NoBlending,\n OrthographicCamera,\n Path,\n PlaneGeometry,\n Raycaster,\n RedFormat,\n Scene,\n ShaderMaterial,\n Shape,\n ShapeGeometry,\n SRGBColorSpace,\n Vector2,\n Vector3,\n WebGLRenderer,\n WebGLRenderTarget,\n type Material,\n type Texture,\n} from \"three\"\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration with\n * three.js: a milled case around a recessed plate, a volume knob, and 82\n * keycaps, shaded in three flat tone bands and outlined in ink traced from\n * depth and normals. Keys spring down under the real keyboard; chords are\n * recorded for a shortcut step in onboarding.\n *\n * Units are keys: 1 = one 1u keycap pitch. The desk is the XZ plane, y up,\n * +z toward the viewer.\n */\n\nexport type MechKeyboardPlatform = \"mac\" | \"pc\"\n\nexport type MechKeyboardKey = {\n /** `KeyboardEvent.code` of the key, or `\"Fn\"` */\n id: string\n /** Legend, as printed */\n label: string\n /** Shifted legend, printed after the label (`\"1 !\"`) */\n sub?: string\n /** Position from the top left of the plate and width, in key units */\n x: number\n y: number\n w: number\n /** Row: 0 is the function row */\n row: number\n}\n\nexport type MechKeyboardShortcut = {\n /** `KeyboardEvent.code`s, modifiers first */\n codes: string[]\n /** Display legends, one per key: `[\"⌘\", \"⇧\", \"K\"]` */\n keys: string[]\n /** `\"⌘⇧K\"` on Mac, `\"Ctrl+Shift+K\"` elsewhere */\n label: string\n}\n\nexport type MechKeyboardChordPhase = \"idle\" | \"live\" | \"done\"\n\nexport type MechKeyboardOptions = {\n /** Offsets in px; +y is down. Default 0. */\n x?: number\n y?: number\n /** Multiplies the fitted size. Default 1. */\n scale?: number\n /** Ink and greys only; no blue caps. Default false. */\n monochrome?: boolean\n /** Real key presses and clicks drive the keys. Default true. */\n interactive?: boolean\n /** Synthesized switch sounds. Default false. */\n sound?: boolean\n /** The keyboard rises into place and a wave runs across the keys. Default true. */\n intro?: boolean\n /** Codes to pulse. */\n highlight?: string[]\n /** Bottom-row legends and shortcut labels. Default: detected. */\n platform?: MechKeyboardPlatform\n /** Leave room at the top for the recorder. Default false. */\n recorder?: boolean\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n /** Live chord for the recorder. */\n onChord?: (codes: string[], phase: MechKeyboardChordPhase) => void\n}\n\nexport type MechKeyboardInstance = {\n setOptions: (next: Partial) => void\n /** Plays the intro again. */\n replay: () => void\n destroy: () => void\n}\n\nexport const DEFAULT_PLACEHOLDER = \"Press a shortcut\"\n\n/* ------------------------------------------------------------------ */\n/* Palette */\n/* ------------------------------------------------------------------ */\n\ntype Palette = {\n case: string\n plate: string\n /** Caps fade from the back left to the front right. */\n capBack: string\n capFront: string\n knob: string\n /** Recorder keycaps: top face and skirt */\n chip: string\n chipSide: string\n}\n\n/** Outlines, legends, and the knob's notch. */\nconst INK = \"#2B3442\"\nconst HIGHLIGHT = \"#7E9BBA\"\n\nconst FROST: Palette = {\n case: \"#E6E7E9\",\n plate: \"#8E96A3\",\n capBack: \"#AFC3D7\",\n capFront: \"#F1F2F4\",\n knob: \"#E2E4E7\",\n chip: \"#E9EEF4\",\n chipSide: \"#AFC3D7\",\n}\n\nconst MONO: Palette = {\n case: \"#F2F2F3\",\n plate: \"#A3A6AC\",\n capBack: \"#FAFAFA\",\n capFront: \"#FAFAFA\",\n knob: \"#F2F2F3\",\n chip: \"#FAFAFA\",\n chipSide: \"#CDD0D5\",\n}\n\nconst paletteFor = (monochrome: boolean) => (monochrome ? MONO : FROST)\n\n/** CSS variables the recorder keycaps read. */\nexport function recorderVars(monochrome = false) {\n const p = paletteFor(monochrome)\n return { \"--chip\": p.chip, \"--chip-side\": p.chipSide, \"--ink\": INK }\n}\n\n/* ------------------------------------------------------------------ */\n/* Layout */\n/* ------------------------------------------------------------------ */\n\nconst COLS = 16\n/** The function row, a quarter-unit gap, then five rows. */\nconst LAYOUT_D = 6.25\nconst KNOB_ID = \"AudioVolumeMute\"\n\ntype Spec = [id: string, w: number, label?: string, sub?: string]\n\nconst GAP_KEY = \"__gap\"\n\nconst gap = (w: number): Spec => [GAP_KEY, w]\nconst letters = (s: string): Spec[] => s.split(\"\").map((c) => [`Key${c}`, 1, c])\nconst fkeys = (from: number): Spec[] =>\n [0, 1, 2, 3].map((i) => [`F${from + i}`, 1, `F${from + i}`])\n\nfunction rows(platform: MechKeyboardPlatform): Spec[][] {\n const mac = platform === \"mac\"\n const bottom: Spec[] = mac\n ? [\n [\"ControlLeft\", 1.25, \"Control\"],\n [\"AltLeft\", 1.25, \"Option\"],\n [\"MetaLeft\", 1.25, \"Command\"],\n [\"Space\", 6.25, \"\"],\n [\"MetaRight\", 1, \"⌘\"],\n [\"AltRight\", 1, \"⌥\"],\n [\"Fn\", 1, \"Fn\"],\n ]\n : [\n [\"ControlLeft\", 1.25, \"Ctrl\"],\n [\"MetaLeft\", 1.25, \"Win\"],\n [\"AltLeft\", 1.25, \"Alt\"],\n [\"Space\", 6.25, \"\"],\n [\"AltRight\", 1, \"Alt\"],\n [\"Fn\", 1, \"Fn\"],\n [\"ControlRight\", 1, \"Ctrl\"],\n ]\n return [\n [\n [\"Escape\", 1, \"Esc\"],\n gap(0.25),\n ...fkeys(1),\n gap(0.25),\n ...fkeys(5),\n gap(0.25),\n ...fkeys(9),\n gap(0.25),\n [\"Delete\", 1, \"Del\"],\n ],\n [\n [\"Backquote\", 1, \"`\", \"~\"],\n [\"Digit1\", 1, \"1\", \"!\"],\n [\"Digit2\", 1, \"2\", \"@\"],\n [\"Digit3\", 1, \"3\", \"#\"],\n [\"Digit4\", 1, \"4\", \"$\"],\n [\"Digit5\", 1, \"5\", \"%\"],\n [\"Digit6\", 1, \"6\", \"^\"],\n [\"Digit7\", 1, \"7\", \"&\"],\n [\"Digit8\", 1, \"8\", \"*\"],\n [\"Digit9\", 1, \"9\", \"(\"],\n [\"Digit0\", 1, \"0\", \")\"],\n [\"Minus\", 1, \"-\", \"_\"],\n [\"Equal\", 1, \"=\", \"+\"],\n [\"Backspace\", 2, \"Backspace\"],\n [\"Home\", 1, \"Home\"],\n ],\n [\n [\"Tab\", 1.5, \"Tab\"],\n ...letters(\"QWERTYUIOP\"),\n [\"BracketLeft\", 1, \"[\", \"{\"],\n [\"BracketRight\", 1, \"]\", \"}\"],\n [\"Backslash\", 1.5, \"\\\\\", \"|\"],\n [\"PageUp\", 1, \"PgUp\"],\n ],\n [\n [\"CapsLock\", 1.75, \"Caps\"],\n ...letters(\"ASDFGHJKL\"),\n [\"Semicolon\", 1, \";\", \":\"],\n [\"Quote\", 1, \"'\", '\"'],\n [\"Enter\", 2.25, \"Enter\"],\n [\"PageDown\", 1, \"PgDn\"],\n ],\n [\n [\"ShiftLeft\", 2.25, \"Shift\"],\n ...letters(\"ZXCVBNM\"),\n [\"Comma\", 1, \",\", \"<\"],\n [\"Period\", 1, \".\", \">\"],\n [\"Slash\", 1, \"/\", \"?\"],\n [\"ShiftRight\", 1.75, \"Shift\"],\n [\"ArrowUp\", 1, \"↑\"],\n [\"End\", 1, \"End\"],\n ],\n [\n ...bottom,\n [\"ArrowLeft\", 1, \"←\"],\n [\"ArrowDown\", 1, \"↓\"],\n [\"ArrowRight\", 1, \"→\"],\n ],\n ]\n}\n\n/** The 82 keys of a 75% board, Mac or PC bottom row. */\nexport function buildLayout(platform: MechKeyboardPlatform): MechKeyboardKey[] {\n const keys: MechKeyboardKey[] = []\n rows(platform).forEach((row, r) => {\n const y = r === 0 ? 0 : r + 0.25\n let x = 0\n for (const [id, w, label = \"\", sub] of row) {\n if (id !== GAP_KEY) keys.push({ id, label, sub, x, y, w, row: r })\n x += w\n }\n })\n return keys\n}\n\n/* ------------------------------------------------------------------ */\n/* Codes, labels, shortcuts */\n/* ------------------------------------------------------------------ */\n\nexport function detectPlatform(): MechKeyboardPlatform {\n if (typeof navigator === \"undefined\") return \"pc\"\n const nav = navigator as Navigator & {\n userAgentData?: { platform?: string }\n }\n const name = nav.userAgentData?.platform || nav.platform || nav.userAgent\n return /mac|iphone|ipad|ipod/i.test(name) ? \"mac\" : \"pc\"\n}\n\nconst MODIFIER_ORDER: Record = {\n mac: [\"Control\", \"Alt\", \"Shift\", \"Meta\", \"Fn\"],\n pc: [\"Control\", \"Meta\", \"Alt\", \"Shift\", \"Fn\"],\n}\n\n/** `\"ShiftLeft\"` → `\"Shift\"`; anything else → undefined. */\nexport function modifierOf(code: string) {\n if (code === \"Fn\") return \"Fn\"\n const match = /^(Control|Alt|Shift|Meta|OS)(Left|Right)$/.exec(code)\n if (!match) return undefined\n return match[1] === \"OS\" ? \"Meta\" : match[1]\n}\n\nconst MAC_LABELS: Record = {\n Control: \"⌃\",\n Alt: \"⌥\",\n Shift: \"⇧\",\n Meta: \"⌘\",\n Fn: \"fn\",\n Escape: \"esc\",\n Tab: \"⇥\",\n CapsLock: \"⇪\",\n Enter: \"↩\",\n Backspace: \"⌫\",\n Delete: \"⌦\",\n Space: \"Space\",\n}\n\nconst PC_LABELS: Record = {\n Control: \"Ctrl\",\n Alt: \"Alt\",\n Shift: \"Shift\",\n Meta: \"Win\",\n Fn: \"Fn\",\n Escape: \"Esc\",\n Tab: \"Tab\",\n CapsLock: \"Caps\",\n Enter: \"Enter\",\n Backspace: \"Backspace\",\n Delete: \"Del\",\n Space: \"Space\",\n}\n\nconst SHARED_LABELS: Record = {\n ArrowUp: \"↑\",\n ArrowDown: \"↓\",\n ArrowLeft: \"←\",\n ArrowRight: \"→\",\n PageUp: \"PgUp\",\n PageDown: \"PgDn\",\n Home: \"Home\",\n End: \"End\",\n Insert: \"Ins\",\n Minus: \"-\",\n Equal: \"=\",\n BracketLeft: \"[\",\n BracketRight: \"]\",\n Backslash: \"\\\\\",\n IntlBackslash: \"\\\\\",\n Semicolon: \";\",\n Quote: \"'\",\n Comma: \",\",\n Period: \".\",\n Slash: \"/\",\n Backquote: \"`\",\n NumpadEnter: \"Enter\",\n AudioVolumeMute: \"Mute\",\n AudioVolumeUp: \"Vol +\",\n AudioVolumeDown: \"Vol −\",\n}\n\n/** One legend for a `KeyboardEvent.code`. */\nexport function keyLabel(code: string, platform: MechKeyboardPlatform) {\n const table = platform === \"mac\" ? MAC_LABELS : PC_LABELS\n const name = modifierOf(code) ?? code\n if (table[name]) return table[name]\n if (SHARED_LABELS[name]) return SHARED_LABELS[name]\n const match = /^(?:Key|Digit|Numpad)(.+)$/.exec(name)\n if (match) return match[1]!\n return name\n}\n\n/** Modifiers first in platform order (one per kind), then keys as pressed. */\nexport function formatShortcut(\n codes: string[],\n platform: MechKeyboardPlatform\n): MechKeyboardShortcut {\n const order = MODIFIER_ORDER[platform]\n const mods = new Map()\n const rest: string[] = []\n for (const code of codes) {\n const mod = modifierOf(code)\n if (mod) {\n if (!mods.has(mod)) mods.set(mod, code)\n } else if (!rest.includes(code)) {\n rest.push(code)\n }\n }\n const sorted = [\n ...order.filter((m) => mods.has(m)).map((m) => mods.get(m)!),\n ...rest,\n ]\n const keys = sorted.map((code) => keyLabel(code, platform))\n return {\n codes: sorted,\n keys,\n label: platform === \"mac\" ? keys.join(\"\") : keys.join(\"+\"),\n }\n}\n\nconst ALIASES: Record = {\n Insert: [\"Delete\"],\n NumpadEnter: [\"Enter\"],\n IntlBackslash: [\"Backslash\"],\n OSLeft: [\"MetaLeft\"],\n OSRight: [\"MetaRight\"],\n NumpadDecimal: [\"Period\"],\n NumpadDivide: [\"Slash\"],\n NumpadSubtract: [\"Minus\"],\n NumpadAdd: [\"Equal\"],\n AudioVolumeUp: [],\n AudioVolumeDown: [],\n}\n\n/** Which keycaps a code moves. */\nfunction capsFor(code: string): string[] {\n const pad = /^Numpad(\\d)$/.exec(code)\n if (pad) return [`Digit${pad[1]}`]\n return ALIASES[code] ?? [code]\n}\n\n/* ------------------------------------------------------------------ */\n/* Geometry */\n/* ------------------------------------------------------------------ */\n\nconst DEG = Math.PI / 180\n/** True isometric: the desk turned 45° and seen from 35.26° up. */\nconst ISO_TILT = 35.26 * DEG\nconst ISO_TURN = 45 * DEG\n/** Bezel around the plate */\nconst BEZEL = 0.48\nconst W = COLS + BEZEL * 2\nconst D = LAYOUT_D + BEZEL * 2\n/** Case height at the front; the back rises by RISE (typing angle). */\nconst CASE_H = 0.95\nconst RISE = 0.4\nconst TILT = Math.atan2(RISE, D)\n/** Bezel top down to the top of the plate */\nconst WELL = 0.5\n/** Gap around a cap in its cell, switch height under the cap, key travel */\nconst GAP = 0.035\nconst SWITCH = 0.12\nconst TRAVEL = 0.16\n/** Keycap: height, skirt taper, top edge radius, corner radius */\nconst CAP_H = 0.5\nconst CAP_TAPER = 0.07\nconst CAP_EDGE = 0.018\nconst CAP_RADIUS = 0.1\n\nfunction roundedRect(path: T, w: number, h: number, r: number) {\n const x = -w / 2\n const y = -h / 2\n path.moveTo(x + r, y)\n path.lineTo(x + w - r, y)\n path.quadraticCurveTo(x + w, y, x + w, y + r)\n path.lineTo(x + w, y + h - r)\n path.quadraticCurveTo(x + w, y + h, x + w - r, y + h)\n path.lineTo(x + r, y + h)\n path.quadraticCurveTo(x, y + h, x, y + h - r)\n path.lineTo(x, y + r)\n path.quadraticCurveTo(x, y, x + r, y)\n return path\n}\n\n/**\n * The milled frame around the well: extruded with one-segment bevels, so\n * every edge is a flat chamfer the ink can trace, then sheared into a\n * wedge.\n */\nfunction caseGeometry() {\n const chamfer = 0.05\n const outer = roundedRect(new Shape(), W - chamfer * 2, D - chamfer * 2, 0.16)\n outer.holes.push(\n roundedRect(\n new Path(),\n COLS + 0.12 + chamfer * 2,\n LAYOUT_D + 0.12 + chamfer * 2,\n 0.06\n )\n )\n const geometry = new ExtrudeGeometry(outer, {\n depth: CASE_H - chamfer * 2,\n bevelEnabled: true,\n bevelThickness: chamfer,\n bevelSize: chamfer,\n bevelSegments: 1,\n curveSegments: 8,\n })\n geometry.rotateX(-Math.PI / 2)\n geometry.translate(0, chamfer, 0)\n const pos = geometry.attributes.position!\n for (let i = 0; i < pos.count; i++) {\n const y = pos.getY(i)\n const back = Math.min(1, Math.max(0, (D / 2 - pos.getZ(i)) / D))\n pos.setY(i, y + back * RISE * Math.min(1, Math.max(0, y / CASE_H)))\n }\n geometry.computeVertexNormals()\n return geometry\n}\n\n/** Top of the bezel at depth z (0 is the middle). */\nconst bezelTop = (z: number) => CASE_H + ((D / 2 - z) / D) * RISE\n\n/** Points around a rounded rectangle, back right corner first. */\nfunction ring(hw: number, hd: number, r: number, seg: number) {\n const out: Array<[number, number]> = []\n const corners: Array<[number, number, number]> = [\n [hw - r, -(hd - r), -90],\n [hw - r, hd - r, 0],\n [-(hw - r), hd - r, 90],\n [-(hw - r), -(hd - r), 180],\n ]\n for (const [cx, cz, a0] of corners) {\n for (let i = 0; i <= seg; i++) {\n const a = (a0 + (90 * i) / seg) * DEG\n out.push([cx + Math.cos(a) * r, cz + Math.sin(a) * r])\n }\n }\n return out\n}\n\n/**\n * A keycap as one smooth surface: a tapered skirt and a crisp top edge.\n * UVs are a projection from above over the footprint, so a canvas the size\n * of the footprint prints its legend onto the top.\n */\nfunction capGeometry(w: number) {\n const fw = w - GAP * 2\n const fd = 1 - GAP * 2\n const profile: Array<[inset: number, y: number]> = []\n for (let i = 0; i <= 2; i++) {\n const t = i / 2\n profile.push([CAP_TAPER * t, (CAP_H - CAP_EDGE) * t])\n }\n for (let i = 1; i <= 3; i++) {\n const a = (i / 3) * (Math.PI / 2)\n profile.push([\n CAP_TAPER + CAP_EDGE * (1 - Math.cos(a)),\n CAP_H - CAP_EDGE + CAP_EDGE * Math.sin(a),\n ])\n }\n\n const rings: Array> = []\n for (const [inset, y] of profile) {\n const r = Math.max(0.03, CAP_RADIUS - inset * 0.35)\n rings.push(\n ring(fw / 2 - inset, fd / 2 - inset, r, 5).map(([x, z]) => [x, y, z])\n )\n }\n const top = rings[rings.length - 1]!\n for (const s of [0.5]) rings.push(top.map(([x, y, z]) => [x * s, y, z * s]))\n\n const positions: number[] = []\n const uvs: number[] = []\n for (const points of rings) {\n for (const [x, y, z] of points) {\n positions.push(x, y, z)\n uvs.push(0.5 + x / fw, 0.5 - z / fd)\n }\n }\n positions.push(0, CAP_H, 0)\n uvs.push(0.5, 0.5)\n\n const n = top.length\n const index: number[] = []\n for (let i = 0; i < rings.length - 1; i++) {\n for (let j = 0; j < n; j++) {\n const a = i * n + j\n const b = i * n + ((j + 1) % n)\n const c = (i + 1) * n + j\n const d = (i + 1) * n + ((j + 1) % n)\n index.push(a, c, b, b, c, d)\n }\n }\n const center = rings.length * n\n const last = (rings.length - 1) * n\n for (let j = 0; j < n; j++) {\n index.push(last + j, center, last + ((j + 1) % n))\n }\n\n const geometry = new BufferGeometry()\n geometry.setAttribute(\"position\", new Float32BufferAttribute(positions, 3))\n geometry.setAttribute(\"uv\", new Float32BufferAttribute(uvs, 2))\n geometry.setIndex(index)\n geometry.computeVertexNormals()\n return geometry\n}\n\n/** The volume knob, turned on a lathe. */\nfunction knobGeometry() {\n const r = 0.4\n const h = 0.62\n const points = [\n new Vector2(0, 0),\n new Vector2(r - 0.02, 0),\n new Vector2(r, 0.02),\n new Vector2(r, h - 0.03),\n new Vector2(r - 0.03, h),\n new Vector2(0, h),\n ]\n return new LatheGeometry(points, 64)\n}\n\n/* ------------------------------------------------------------------ */\n/* Textures */\n/* ------------------------------------------------------------------ */\n\nfunction canvas(w: number, h: number) {\n const el = document.createElement(\"canvas\")\n el.width = w\n el.height = h\n return [el, el.getContext(\"2d\")!] as const\n}\n\n/** Three flat tone bands. */\nfunction rampTexture() {\n const texture = new DataTexture(\n new Uint8Array([120, 196, 255]),\n 3,\n 1,\n RedFormat\n )\n texture.minFilter = NearestFilter\n texture.magFilter = NearestFilter\n texture.generateMipmaps = false\n texture.needsUpdate = true\n return texture\n}\n\nconst LEGEND_PX = 256\n\n/** Arrow legends, as the angle of a right-pointing arrow. */\nconst ARROWS: Record = {\n \"→\": 0,\n \"↓\": Math.PI / 2,\n \"←\": Math.PI,\n \"↑\": -Math.PI / 2,\n}\n\n/** The cap's color with its legend in ink, like the outlines. */\nfunction capTexture(key: MechKeyboardKey, color: string, font: string) {\n const fw = key.w - GAP * 2\n const fd = 1 - GAP * 2\n const [el, ctx] = canvas(\n Math.round(fw * LEGEND_PX),\n Math.round(fd * LEGEND_PX)\n )\n ctx.fillStyle = color\n ctx.fillRect(0, 0, el.width, el.height)\n ctx.fillStyle = INK\n ctx.strokeStyle = INK\n const cx = el.width / 2\n const cy = el.height * 0.47\n const arrow = ARROWS[key.label]\n if (arrow !== undefined) {\n // Drawn, not typed: plenty of UI fonts have no ← or →.\n const len = 0.17 * LEGEND_PX\n ctx.save()\n ctx.translate(cx, cy)\n ctx.rotate(arrow)\n ctx.lineWidth = 0.02 * LEGEND_PX\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n ctx.beginPath()\n ctx.moveTo(-len / 2, 0)\n ctx.lineTo(len / 2, 0)\n ctx.moveTo(len / 2 - len * 0.32, -len * 0.3)\n ctx.lineTo(len / 2, 0)\n ctx.lineTo(len / 2 - len * 0.32, len * 0.3)\n ctx.stroke()\n ctx.restore()\n } else {\n const single = [...key.label].length === 1 && !key.sub\n const text = key.sub\n ? `${key.label} ${key.sub}`\n : single\n ? key.label\n : key.label.toUpperCase()\n const size = single ? 0.22 : key.sub ? 0.17 : 0.125\n ctx.font = `600 ${size * LEGEND_PX}px ${font}`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n if (!single) ctx.letterSpacing = `${0.014 * LEGEND_PX}px`\n ctx.fillText(text, cx, cy)\n }\n const texture = new CanvasTexture(el)\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = 8\n return texture\n}\n\n/* ------------------------------------------------------------------ */\n/* Sound */\n/* ------------------------------------------------------------------ */\n\nfunction createThock() {\n let audio: AudioContext | null = null\n let noise: AudioBuffer | null = null\n let master: GainNode | null = null\n return {\n play(code: string, down: boolean) {\n try {\n if (!audio) {\n const Ctx =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext\n audio = new Ctx()\n master = audio.createGain()\n master.gain.value = 0.55\n master.connect(audio.destination)\n noise = audio.createBuffer(\n 1,\n Math.floor(audio.sampleRate * 0.06),\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()\n const t = audio.currentTime\n const big =\n code === \"Space\" ||\n code === \"Enter\" ||\n code === \"Backspace\" ||\n code.startsWith(\"Shift\")\n const vary = 0.93 + Math.random() * 0.14\n\n const body = audio.createOscillator()\n body.type = \"sine\"\n const f = 132 * (big ? 0.72 : 1) * (down ? 1 : 1.3) * vary\n body.frequency.setValueAtTime(f * 1.9, t)\n body.frequency.exponentialRampToValueAtTime(f, t + 0.03)\n const bodyGain = audio.createGain()\n bodyGain.gain.setValueAtTime(down ? 0.32 : 0.1, t)\n bodyGain.gain.exponentialRampToValueAtTime(\n 0.0001,\n t + (down ? 0.1 : 0.05)\n )\n body.connect(bodyGain).connect(master!)\n body.start(t)\n body.stop(t + 0.12)\n\n const hit = audio.createBufferSource()\n hit.buffer = noise\n const band = audio.createBiquadFilter()\n band.type = \"bandpass\"\n band.frequency.value = 2900 * (down ? 1 : 1.25) * vary\n band.Q.value = 1.1\n const hitGain = audio.createGain()\n hitGain.gain.setValueAtTime(down ? 0.5 : 0.22, t)\n hitGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.035)\n hit.connect(band).connect(hitGain).connect(master!)\n hit.start(t)\n hit.stop(t + 0.06)\n } catch {\n /* no audio: stay silent */\n }\n },\n close() {\n void audio?.close()\n },\n }\n}\n\nfunction isEditable(target: EventTarget | null) {\n if (!(target instanceof HTMLElement)) return false\n if (target.isContentEditable) return true\n const tag = target.tagName\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return true\n if (tag !== \"INPUT\") return false\n const type = (target as HTMLInputElement).type\n return ![\n \"checkbox\",\n \"radio\",\n \"button\",\n \"range\",\n \"color\",\n \"submit\",\n \"reset\",\n ].includes(type)\n}\n\n/* ------------------------------------------------------------------ */\n/* Engine */\n/* ------------------------------------------------------------------ */\n\ntype Resolved = Required<\n Omit\n> &\n Pick & {\n highlight: string[]\n }\n\nfunction resolve(options: MechKeyboardOptions): Resolved {\n const num = (v: number | undefined, d: number) =>\n typeof v === \"number\" && Number.isFinite(v) ? v : d\n return {\n x: num(options.x, 0),\n y: num(options.y, 0),\n scale: Math.max(0.05, num(options.scale, 1)),\n monochrome: options.monochrome ?? false,\n interactive: options.interactive ?? true,\n sound: options.sound ?? false,\n intro: options.intro ?? true,\n highlight: options.highlight ?? [],\n platform:\n options.platform === \"mac\" || options.platform === \"pc\"\n ? options.platform\n : detectPlatform(),\n recorder: options.recorder ?? false,\n onKey: options.onKey,\n onShortcut: options.onShortcut,\n onChord: options.onChord,\n }\n}\n\ntype Cap = {\n id: string\n x: number\n y: number\n holder: Group\n /** Rest height of the holder */\n base: number\n /** How far it goes down */\n travel: number\n material: MeshToonMaterial\n pos: number\n vel: number\n target: number\n}\n\nconst NEAR = 0.1\nconst FAR = 200\n\n/**\n * Mounts a canvas in `stage` and listens on `root`\n * (focusable; pointer and keys). Returns a no-op instance without WebGL.\n */\nexport function createMechKeyboard(\n root: HTMLElement,\n stage: HTMLElement,\n initial: MechKeyboardOptions = {}\n): MechKeyboardInstance {\n let opts = resolve(initial)\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({ alpha: true, antialias: true })\n } catch {\n return { setOptions() {}, replay() {}, destroy() {} }\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const el = renderer.domElement\n el.style.display = \"block\"\n el.style.width = \"100%\"\n el.style.height = \"100%\"\n stage.appendChild(el)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduced = mqReduce.matches\n let destroyed = false\n const disposables: Array = []\n const keep = (item: T) => {\n disposables.push(item)\n return item\n }\n\n /* ---------- scene ---------- */\n\n const scene = new Scene()\n const camera = new OrthographicCamera(-1, 1, 1, -1, NEAR, FAR)\n\n // desk: tipped toward the camera. board: turned 45° on the desk.\n const desk = new Group()\n desk.rotation.x = ISO_TILT\n scene.add(desk)\n const board = new Group()\n board.rotation.y = ISO_TURN\n board.position.y = -(CASE_H + RISE / 2) / 2\n desk.add(board)\n\n // Light fixed to the view, the illustrator's convention: tops lightest,\n // left faces middle, right faces darkest.\n const key = new DirectionalLight(0xffffff, 1.9)\n key.position.set(-3, 6, 4)\n scene.add(key, key.target, new AmbientLight(0xffffff, 1.1))\n\n // A flat, hard-edged shadow, offset down and to the right.\n const shadow = new Mesh(\n keep(\n new ShapeGeometry(roundedRect(new Shape(), W, D, 0.16), 8).rotateX(\n -Math.PI / 2\n )\n ),\n keep(\n new MeshBasicMaterial({\n color: INK,\n transparent: true,\n opacity: 0.1,\n depthWrite: false,\n })\n )\n )\n shadow.rotation.y = ISO_TURN\n shadow.position.set(0.55, board.position.y + 0.003, 0.75)\n desk.add(shadow)\n\n const ramp = keep(rampTexture())\n const caseGeo = keep(caseGeometry())\n const knobGeo = keep(knobGeometry())\n const capGeos = new Map()\n const capGeo = (w: number) => {\n let g = capGeos.get(w)\n if (!g) capGeos.set(w, (g = keep(capGeometry(w))))\n return g\n }\n\n /* ---------- the board, rebuilt per palette, platform, and font ---------- */\n\n let built: {\n group: Group\n disposables: Array\n knob: Mesh\n pickable: Mesh[]\n } | null = null\n let builtFor = \"\"\n const caps = new Map()\n let knobAngle = 0\n\n const fontFamily = () =>\n getComputedStyle(root).fontFamily || \"ui-sans-serif, system-ui, sans-serif\"\n const plateX = (x: number) => -COLS / 2 + x\n const plateZ = (y: number) => -LAYOUT_D / 2 + y\n\n const build = () => {\n const font = fontFamily()\n const stamp = `${opts.monochrome}|${opts.platform}|${font}`\n if (stamp === builtFor) return\n builtFor = stamp\n const palette = paletteFor(opts.monochrome)\n const own: Array = []\n const take = (item: T) => {\n own.push(item)\n return item\n }\n const toon = (color: Color | string, map: Texture | null = null) =>\n take(\n new MeshToonMaterial({\n color,\n map,\n gradientMap: ramp,\n emissive: new Color(HIGHLIGHT),\n emissiveIntensity: 0,\n })\n )\n\n const group = new Group()\n const caseMat = toon(palette.case)\n group.add(new Mesh(caseGeo, caseMat))\n const bottom = new Mesh(\n take(new BoxGeometry(W - 0.4, 0.06, D - 0.4)),\n caseMat\n )\n bottom.position.y = 0.03\n group.add(bottom)\n\n // Plate and keys ride the tipped top.\n const deck = new Group()\n deck.position.y = bezelTop(0) - WELL\n deck.rotation.x = TILT\n group.add(deck)\n\n const plate = new Mesh(\n take(new BoxGeometry(COLS + 0.2, 0.08, LAYOUT_D + 0.2)),\n toon(palette.plate)\n )\n plate.position.y = -0.04\n deck.add(plate)\n\n const prev = new Map(caps)\n caps.clear()\n\n const knobMat = toon(palette.knob)\n const knobHolder = new Group()\n knobHolder.position.set(plateX(15.5), 0.08, plateZ(0.5))\n deck.add(knobHolder)\n const knob = new Mesh(knobGeo, knobMat)\n knob.userData.id = KNOB_ID\n knob.rotation.y = knobAngle\n knobHolder.add(knob)\n const notch = new Mesh(\n take(new BoxGeometry(0.05, 0.012, 0.2)),\n take(new MeshBasicMaterial({ color: INK }))\n )\n notch.position.set(0, 0.622, -0.19)\n knob.add(notch)\n const pickable: Mesh[] = [knob]\n\n const next: Cap[] = [\n {\n id: KNOB_ID,\n x: 15,\n y: 0,\n holder: knobHolder,\n base: 0.08,\n travel: 0.06,\n material: knobMat,\n pos: 0,\n vel: 0,\n target: 0,\n },\n ]\n\n const from = new Color(palette.capBack)\n const to = new Color(palette.capFront)\n for (const k of buildLayout(opts.platform)) {\n const cx = plateX(k.x + k.w / 2)\n const cz = plateZ(k.y + 0.5)\n const holder = new Group()\n holder.position.set(cx, SWITCH, cz)\n deck.add(holder)\n\n // The back rows carry the color; it washes out toward the front right.\n const t = Math.min(\n 1,\n Math.max(\n 0,\n (k.y / 5.25) * 0.82 + ((k.x + k.w / 2) / COLS) * 0.26 - 0.08\n )\n )\n const color = from.clone().lerp(to, t)\n const map = k.label ? take(capTexture(k, color.getStyle(), font)) : null\n const material = toon(map ? \"#ffffff\" : color, map)\n const mesh = new Mesh(capGeo(k.w), material)\n mesh.userData.id = k.id\n holder.add(mesh)\n pickable.push(mesh)\n next.push({\n id: k.id,\n x: k.x,\n y: k.y,\n holder,\n base: SWITCH,\n travel: TRAVEL,\n material,\n pos: 0,\n vel: 0,\n target: 0,\n })\n }\n\n // Keys that were down stay down across a rebuild.\n for (const cap of next) {\n const old = prev.get(cap.id)\n if (old) {\n cap.pos = old.pos\n cap.target = old.target\n }\n cap.holder.position.y = cap.base + cap.pos\n caps.set(cap.id, cap)\n }\n\n board.add(group)\n if (built) {\n board.remove(built.group)\n for (const item of built.disposables) item.dispose()\n }\n built = { group, disposables: own, knob, pickable }\n paintHighlights()\n }\n\n /* ---------- ink: edges from depth and normals ---------- */\n\n const color = new WebGLRenderTarget(1, 1, { type: HalfFloatType, samples: 4 })\n const normal = new WebGLRenderTarget(1, 1, { type: HalfFloatType })\n normal.depthTexture = new DepthTexture(1, 1)\n const normalMat = keep(new MeshNormalMaterial())\n const quad = new Mesh(\n keep(new PlaneGeometry(2, 2)),\n keep(\n new ShaderMaterial({\n uniforms: {\n tColor: { value: color.texture },\n tNormal: { value: normal.texture },\n tDepth: { value: normal.depthTexture },\n texel: { value: new Vector2() },\n ink: { value: new Color(INK) },\n depthRange: { value: FAR - NEAR },\n },\n vertexShader: INK_VERTEX,\n fragmentShader: INK_FRAGMENT,\n depthTest: false,\n depthWrite: false,\n blending: NoBlending,\n })\n )\n )\n const post = new Scene()\n post.add(quad)\n const postCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)\n\n const render = () => {\n renderer.setRenderTarget(color)\n renderer.setClearColor(0x000000, 0)\n renderer.render(scene, camera)\n\n shadow.visible = false\n scene.overrideMaterial = normalMat\n renderer.setRenderTarget(normal)\n renderer.setClearColor(0x8080ff, 1)\n renderer.render(scene, camera)\n scene.overrideMaterial = null\n shadow.visible = true\n\n renderer.setClearColor(0x000000, 0)\n renderer.setRenderTarget(null)\n renderer.render(post, postCamera)\n }\n\n /* ---------- fit and offset ---------- */\n\n let width = 0\n let height = 0\n const current = { x: 0, y: 0 }\n let introFrom: { x: number; y: number } | null = null\n let introStart = 0\n const INTRO_MS = 1300\n\n const corners: Vector3[] = []\n for (const x of [-W / 2, W / 2])\n for (const y of [0, CASE_H + RISE + 0.1])\n for (const z of [-D / 2, D / 2]) corners.push(new Vector3(x, y, z))\n const scratch = new Vector3()\n\n /** Frame the board exactly: measure its projected box straight on. */\n const fit = () => {\n if (!width || !height) return\n desk.position.set(0, 0, 0)\n desk.updateMatrixWorld(true)\n let minX = Infinity\n let maxX = -Infinity\n let minY = Infinity\n let maxY = -Infinity\n for (const c of corners) {\n scratch.copy(c).applyMatrix4(board.matrixWorld)\n minX = Math.min(minX, scratch.x)\n maxX = Math.max(maxX, scratch.x)\n minY = Math.min(minY, scratch.y)\n maxY = Math.max(maxY, scratch.y)\n }\n const aspect = width / height\n const room = opts.recorder ? 0.66 : 0.82\n const half =\n Math.max((maxY - minY) / 2 / room, (maxX - minX) / 2 / 0.88 / aspect) /\n opts.scale\n camera.left = -half * aspect\n camera.right = half * aspect\n camera.top = half\n camera.bottom = -half\n camera.position.set((minX + maxX) / 2, (minY + maxY) / 2, FAR / 2)\n camera.lookAt(camera.position.x, camera.position.y, 0)\n camera.updateProjectionMatrix()\n place()\n }\n\n function place() {\n const perPx = (camera.top - camera.bottom) / (height || 1)\n const drop = opts.recorder ? height * 0.04 : 0\n desk.position.set(current.x * perPx, -(current.y + drop) * perPx, 0)\n }\n\n const resize = () => {\n const w = stage.clientWidth\n const h = stage.clientHeight\n if (!w || !h || (w === width && h === height)) return false\n width = w\n height = h\n renderer.setSize(w, h, false)\n const pr = renderer.getPixelRatio()\n const pw = Math.max(1, Math.round(w * pr))\n const ph = Math.max(1, Math.round(h * pr))\n // Normals and depth at twice the resolution on 1x screens, so the\n // four-tap average in the ink pass has subpixels to antialias with.\n const s = pr < 2 ? 2 : 1\n color.setSize(pw, ph)\n normal.setSize(pw * s, ph * s)\n quad.material.uniforms.texel!.value.set(1 / pw, 1 / ph)\n fit()\n return true\n }\n\n /* ---------- loop ---------- */\n\n let raf = 0\n let last = 0\n let onScreen = true\n let pulse = 0\n let knobGoal = 0\n\n const wake = () => {\n if (raf || destroyed || !onScreen || document.hidden) return\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n\n const frame = (now: number) => {\n raf = 0\n if (destroyed) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n let busy = false\n\n // Offset: the intro rise, then exponential smoothing toward the props.\n if (introFrom) {\n const t = Math.min(1, (now - introStart) / INTRO_MS)\n const e = 1 - Math.pow(1 - t, 4)\n current.x = introFrom.x + (opts.x - introFrom.x) * e\n current.y = introFrom.y + (opts.y - introFrom.y) * e\n if (t >= 1) introFrom = null\n busy = true\n } else {\n const a = reduced ? 1 : 1 - Math.exp(-dt * 7)\n for (const k of [\"x\", \"y\"] as const) {\n const delta = opts[k] - current[k]\n if (Math.abs(delta) > 0.05) {\n current[k] += delta * a\n busy = true\n } else {\n current[k] = opts[k]\n }\n }\n }\n place()\n\n // Keys: a stiff spring down, a bouncier one back up, in fixed 4 ms\n // substeps (one Euler step this stiff blows up on a slow frame).\n const steps = Math.max(1, Math.ceil(dt / 0.004))\n const h = dt / steps\n for (const cap of caps.values()) {\n if (cap.pos === cap.target && cap.vel === 0) continue\n const down = cap.target < cap.pos\n const k = down ? 2600 : 900\n const c = down ? 80 : 26\n for (let i = 0; i < steps; i++) {\n cap.vel += (-k * (cap.pos - cap.target) - c * cap.vel) * h\n cap.pos += cap.vel * h\n }\n if (\n reduced ||\n (Math.abs(cap.pos - cap.target) < 0.0005 && Math.abs(cap.vel) < 0.01)\n ) {\n cap.pos = cap.target\n cap.vel = 0\n } else {\n busy = true\n }\n cap.holder.position.y = cap.base + cap.pos\n }\n\n const turn = knobGoal - knobAngle\n if (Math.abs(turn) > 0.0005) {\n knobAngle += turn * (reduced ? 1 : 1 - Math.exp(-dt * 14))\n if (built) built.knob.rotation.y = knobAngle\n busy = true\n }\n\n if (opts.highlight.length) {\n pulse += dt\n const glow = reduced ? 0.3 : 0.2 + 0.16 * Math.sin(pulse * 4)\n for (const id of opts.highlight) {\n const cap = caps.get(id)\n if (cap) cap.material.emissiveIntensity = glow\n }\n if (!reduced) busy = true\n }\n\n render()\n if (busy) wake()\n }\n\n function paintHighlights() {\n const set = new Set(opts.highlight)\n for (const [id, cap] of caps) {\n cap.material.emissiveIntensity = set.has(id) ? 0.3 : 0\n }\n }\n\n /* ---------- keys ---------- */\n\n const held = new Set()\n const latched = new Set()\n let chord: string[] = []\n const thock = createThock()\n\n const capFor = (id: string) => {\n const cap = caps.get(id)\n if (cap || !/(Left|Right)$/.test(id)) return cap\n const twin = id.endsWith(\"Left\")\n ? id.replace(/Left$/, \"Right\")\n : id.replace(/Right$/, \"Left\")\n return caps.get(twin)\n }\n\n const paint = () => {\n const down = new Set()\n for (const code of [...held, ...latched]) {\n for (const id of capsFor(code)) {\n const cap = capFor(id)\n if (cap) down.add(cap)\n }\n }\n for (const cap of caps.values()) {\n cap.target = down.has(cap) ? -cap.travel : 0\n }\n wake()\n }\n\n const sound = (code: string, down: boolean) => {\n if (opts.sound) thock.play(code, down)\n }\n\n const press = (code: string) => {\n if (held.has(code)) return\n if (code === \"AudioVolumeUp\") knobGoal -= 0.35\n if (code === \"AudioVolumeDown\") knobGoal += 0.35\n if (held.size === 0 && latched.size === 0) chord = []\n held.add(code)\n if (!chord.includes(code)) chord.push(code)\n paint()\n sound(code, true)\n opts.onKey?.(code, true)\n opts.onChord?.([...chord], \"live\")\n }\n\n const commit = () => {\n if (held.size || latched.size || chord.length === 0) return\n const done = chord\n chord = []\n opts.onChord?.(done, \"done\")\n opts.onShortcut?.(formatShortcut(done, opts.platform))\n }\n\n const release = (code: string) => {\n if (!held.delete(code)) return\n paint()\n sound(code, false)\n opts.onKey?.(code, false)\n commit()\n }\n\n const releaseAll = () => {\n const codes = [...held]\n held.clear()\n latched.clear()\n paint()\n for (const code of codes) opts.onKey?.(code, false)\n commit()\n }\n\n const timers = new Set>()\n const later = (fn: () => void, ms: number) => {\n const id = setTimeout(() => {\n timers.delete(id)\n if (!destroyed) fn()\n }, ms)\n timers.add(id)\n }\n\n // The real OS, not the `platform` legends option: it decides which\n // keyboard events arrive.\n const onMac = detectPlatform() === \"mac\"\n\n const focused = () =>\n document.activeElement === root || root.contains(document.activeElement)\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!opts.interactive || !event.code) return\n if (isEditable(event.target) && !root.contains(event.target as Node)) return\n if (focused() && event.code !== \"Tab\") event.preventDefault()\n if (event.repeat) return\n press(event.code)\n // macOS fires one event per Caps Lock toggle; never leave it stuck.\n // Elsewhere Caps Lock sends a normal down and up, so it can be held.\n if (event.code === \"CapsLock\" && onMac) {\n later(() => release(\"CapsLock\"), 160)\n }\n }\n\n const onKeyUp = (event: KeyboardEvent) => {\n if (!event.code) return\n if (event.code === \"CapsLock\" && !held.has(\"CapsLock\")) {\n if (!opts.interactive) return\n press(\"CapsLock\")\n later(() => release(\"CapsLock\"), 120)\n return\n }\n // While ⌘ is held macOS swallows other keyups; release them with it.\n if (modifierOf(event.code) === \"Meta\") {\n for (const code of [...held]) if (!modifierOf(code)) release(code)\n }\n release(event.code)\n }\n\n /* Pointer: modifiers latch, a non-modifier fires the chord. */\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const pick = (event: { clientX: number; clientY: number }) => {\n if (!built) return null\n const r = el.getBoundingClientRect()\n if (!r.width || !r.height) return null\n ndc.set(\n ((event.clientX - r.left) / r.width) * 2 - 1,\n -((event.clientY - r.top) / r.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n const hit = raycaster.intersectObjects(built.pickable, false)[0]\n return (hit?.object.userData.id as string | undefined) ?? null\n }\n\n /** Keys held down by each pointer, so two fingers release independently. */\n const pointers = new Map()\n\n const onPointerDown = (event: PointerEvent) => {\n if (!opts.interactive || event.button !== 0) return\n const code = pick(event)\n if (!code) return\n event.preventDefault()\n root.focus({ preventScroll: true })\n if (modifierOf(code)) {\n if (latched.has(code)) {\n latched.delete(code)\n chord = chord.filter((c) => c !== code)\n paint()\n opts.onChord?.([...chord], chord.length ? \"live\" : \"idle\")\n } else {\n if (held.size === 0 && latched.size === 0) chord = []\n latched.add(code)\n if (!chord.includes(code)) chord.push(code)\n paint()\n sound(code, true)\n opts.onChord?.([...chord], \"live\")\n }\n return\n }\n pointers.set(event.pointerId, code)\n press(code)\n }\n\n const onPointerUp = (event: PointerEvent) => {\n const code = pointers.get(event.pointerId)\n if (!code) return\n pointers.delete(event.pointerId)\n if (![...pointers.values()].includes(code)) {\n held.delete(code)\n sound(code, false)\n opts.onKey?.(code, false)\n }\n // Latched modifiers belong to the chord until the last finger lifts.\n if (pointers.size === 0) latched.clear()\n paint()\n commit()\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!opts.interactive || event.pointerType !== \"mouse\") return\n el.style.cursor = pick(event) ? \"pointer\" : \"\"\n }\n\n /* The knob turns under the wheel, like the real one. */\n const onWheel = (event: WheelEvent) => {\n if (!opts.interactive || pick(event) !== KNOB_ID) return\n event.preventDefault()\n knobGoal += Math.sign(event.deltaY) * 0.26\n wake()\n }\n\n const onBlur = () => {\n if (held.size || latched.size) releaseAll()\n }\n\n window.addEventListener(\"keydown\", onKeyDown)\n window.addEventListener(\"keyup\", onKeyUp)\n window.addEventListener(\"blur\", onBlur)\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"pointermove\", onPointerMove)\n root.addEventListener(\"wheel\", onWheel, { passive: false })\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onPointerUp)\n\n /* ---------- lifecycle ---------- */\n\n const ro = new ResizeObserver(() => {\n if (!resize() || destroyed) return\n // Resizing cleared the canvas; draw the current frame now.\n render()\n wake()\n })\n ro.observe(stage)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(stage)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => {\n reduced = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n // Legends are printed from a canvas; print them again once fonts land.\n void document.fonts?.ready.then(() => {\n if (destroyed) return\n builtFor = \"\"\n build()\n wake()\n })\n\n const wave = () => {\n for (const cap of caps.values()) {\n const delay = 380 + cap.x * 36 + cap.y * 24\n later(() => {\n if (cap.target !== 0) return\n cap.target = -cap.travel * 0.8\n wake()\n later(paint, 110)\n }, delay)\n }\n }\n\n const intro = () => {\n stage.style.transition = \"opacity 800ms ease-out\"\n if (!opts.intro || reduced) {\n current.x = opts.x\n current.y = opts.y\n stage.style.opacity = \"1\"\n wake()\n return\n }\n introFrom = { x: opts.x, y: opts.y + 60 }\n Object.assign(current, introFrom)\n introStart = performance.now()\n stage.style.opacity = \"0\"\n requestAnimationFrame(() => {\n if (!destroyed) stage.style.opacity = \"1\"\n })\n wave()\n wake()\n }\n\n build()\n resize()\n intro()\n\n return {\n setOptions(next) {\n opts = resolve({ ...opts, ...next })\n if (!opts.interactive && (held.size || latched.size)) releaseAll()\n build()\n fit()\n paintHighlights()\n wake()\n },\n replay() {\n intro()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n for (const id of timers) clearTimeout(id)\n timers.clear()\n ro.disconnect()\n io.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"keydown\", onKeyDown)\n window.removeEventListener(\"keyup\", onKeyUp)\n window.removeEventListener(\"blur\", onBlur)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"wheel\", onWheel)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onPointerUp)\n thock.close()\n if (built) for (const item of built.disposables) item.dispose()\n for (const item of disposables) item.dispose()\n color.dispose()\n normal.depthTexture?.dispose()\n normal.dispose()\n renderer.dispose()\n // dispose() keeps the context until garbage collection, and browsers\n // cap live contexts; give it back now.\n renderer.forceContextLoss()\n el.remove()\n },\n }\n}\n\nconst INK_VERTEX = /* glsl */ `\nvarying vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n`\n\n/**\n * Ink where depth jumps (silhouettes, caps over the plate) or the surface\n * turns sharply (case chamfers, cap edges). Four subpixel taps averaged,\n * so the lines come out antialiased.\n */\nconst INK_FRAGMENT = /* glsl */ `\nuniform sampler2D tColor;\nuniform sampler2D tNormal;\nuniform sampler2D tDepth;\nuniform vec2 texel;\nuniform vec3 ink;\nuniform float depthRange;\nvarying vec2 vUv;\n\nfloat edgeAt(vec2 uv) {\n float d0 = texture2D(tDepth, uv).r;\n vec3 n0 = texture2D(tNormal, uv).rgb * 2.0 - 1.0;\n float e = 0.0;\n vec2 dirs[4];\n dirs[0] = vec2(1.0, 0.0);\n dirs[1] = vec2(-1.0, 0.0);\n dirs[2] = vec2(0.0, 1.0);\n dirs[3] = vec2(0.0, -1.0);\n for (int i = 0; i < 4; i++) {\n vec2 q = uv + dirs[i] * texel * 0.85;\n float dd = abs(texture2D(tDepth, q).r - d0) * depthRange;\n vec3 n1 = texture2D(tNormal, q).rgb * 2.0 - 1.0;\n e = max(e, smoothstep(0.07, 0.14, dd));\n e = max(e, smoothstep(0.22, 0.4, 1.0 - dot(n0, n1)));\n }\n return e;\n}\n\nvoid main() {\n vec4 color = texture2D(tColor, vUv);\n vec2 s = texel * 0.25;\n float e = (\n edgeAt(vUv + vec2(-s.x, -s.y)) +\n edgeAt(vUv + vec2(s.x, -s.y)) +\n edgeAt(vUv + vec2(-s.x, s.y)) +\n edgeAt(vUv + vec2(s.x, s.y))\n ) * 0.25;\n gl_FragColor = mix(color, vec4(ink, 1.0), e);\n #include \n}\n`\n\n/** Tailwind for the recorder, shared by the React and Svelte wrappers. */\nexport const CLASSES = {\n root: \"relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n stage: \"absolute inset-0 opacity-0\",\n recorder:\n \"pointer-events-none absolute inset-x-0 top-[6%] z-10 flex flex-col items-center gap-3 px-4\",\n chips: \"flex min-h-12 flex-wrap items-center justify-center gap-2\",\n /**\n * An illustrated keycap: an ink outline around a darker skirt, with the\n * top face sitting on it. Held keys sink into the skirt.\n */\n chip: \"group/chip relative inline-flex h-12 min-w-12 flex-col rounded-[9px] border-[1.5px] border-(--ink) bg-(--chip-side) pb-[6px] font-sans transition-[padding,translate,opacity,scale] duration-200 ease-[cubic-bezier(0.3,1.5,0.5,1)] starting:-translate-y-1.5 starting:scale-90 starting:opacity-0 data-live:translate-y-[3px] data-live:pb-[3px]\",\n chipTop:\n \"flex flex-1 items-center justify-center rounded-[7px] border-b-[1.5px] border-(--ink) bg-(--chip) px-3 text-sm leading-none font-semibold text-(--ink)\",\n plus: \"text-sm font-semibold text-muted-foreground\",\n placeholder: \"text-sm text-muted-foreground\",\n status:\n \"flex h-4 items-center gap-1.5 text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase\",\n dot: \"size-1.5 animate-pulse rounded-full bg-red-500\",\n check: \"size-3\",\n}\n\nexport type MechKeyboardProps = {\n /** Horizontal offset, px. */\n x?: number\n /** Vertical offset, px. */\n y?: number\n /** Multiplies the fitted size. */\n scale?: number\n /** Ink and greys only; no blue caps. */\n monochrome?: boolean\n /** Real key presses and clicks press the keys. */\n interactive?: boolean\n /** Shows the shortcut being pressed above the keyboard. */\n recorder?: boolean\n /** Recorder text before the first key. */\n placeholder?: string\n /** `KeyboardEvent.code`s to pulse, e.g. `[\"MetaLeft\", \"KeyK\"]`. */\n highlight?: string[]\n /** Bottom-row legends. `\"auto\"` detects Mac. */\n platform?: MechKeyboardPlatform | \"auto\"\n /** Synthesized switch sounds. */\n sound?: boolean\n /** Rise into place with a wave across the keys on mount. */\n intro?: boolean\n /** Every key down and up. */\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n className?: string\n}\n\nconst noop = () => () => {}\n\nfunction Check() {\n return (\n \n \n \n )\n}\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration in\n * three.js: flat tone bands, ink outlines, a volume knob. Keys spring down\n * under your keyboard (or a click; modifiers latch), and the recorder above\n * spells out the shortcut in little illustrated keycaps, onboarding-style.\n * Honors `prefers-reduced-motion`.\n */\nexport function MechKeyboard({\n x = 0,\n y = 0,\n scale = 1,\n monochrome = false,\n interactive = true,\n recorder = true,\n placeholder = DEFAULT_PLACEHOLDER,\n highlight,\n platform = \"auto\",\n sound = false,\n intro = true,\n onKey,\n onShortcut,\n className,\n}: MechKeyboardProps) {\n const rootRef = useRef(null)\n const stageRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacks = useRef({ onKey, onShortcut })\n const detected = useSyncExternalStore(\n noop,\n detectPlatform,\n (): MechKeyboardPlatform => \"pc\"\n )\n const [chord, setChord] = useState<{\n codes: string[]\n phase: MechKeyboardChordPhase\n }>({ codes: [], phase: \"idle\" })\n\n const resolvedPlatform = platform === \"auto\" ? detected : platform\n const shortcut = formatShortcut(chord.codes, resolvedPlatform)\n const highlightKey = (highlight ?? []).join(\" \")\n\n useEffect(() => {\n callbacks.current = { onKey, onShortcut }\n }, [onKey, onShortcut])\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n if (!root || !stage) return\n instanceRef.current = createMechKeyboard(root, stage, {\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight,\n platform: resolvedPlatform,\n onKey: (code, pressed) => callbacks.current.onKey?.(code, pressed),\n onShortcut: (s) => callbacks.current.onShortcut?.(s),\n onChord: (codes, phase) => setChord({ codes, phase }),\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 x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight: highlightKey ? highlightKey.split(\" \") : [],\n platform: resolvedPlatform,\n })\n }, [\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlightKey,\n resolvedPlatform,\n ])\n\n return (\n \n
\n {recorder ? (\n
\n
\n {shortcut.keys.length === 0 ? (\n {placeholder}\n ) : (\n shortcut.keys.map((key, i) => (\n \n {i > 0 ? + : null}\n \n {key}\n \n \n ))\n )}\n
\n
\n {chord.phase === \"done\" ? (\n <>\n \n Shortcut saved\n \n ) : chord.phase === \"live\" ? (\n <>\n \n Recording\n \n ) : (\n \"Listening\"\n )}\n
\n
\n ) : null}\n
\n )\n}\n", + "content": "\"use client\"\n\nimport {\n Fragment,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n} from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n AmbientLight,\n BoxGeometry,\n BufferGeometry,\n CanvasTexture,\n Color,\n DataTexture,\n DepthTexture,\n DirectionalLight,\n ExtrudeGeometry,\n Float32BufferAttribute,\n Group,\n HalfFloatType,\n LatheGeometry,\n Mesh,\n MeshBasicMaterial,\n MeshNormalMaterial,\n MeshToonMaterial,\n NearestFilter,\n NoBlending,\n OrthographicCamera,\n Path,\n PlaneGeometry,\n Raycaster,\n RedFormat,\n Scene,\n ShaderMaterial,\n Shape,\n ShapeGeometry,\n SRGBColorSpace,\n Vector2,\n Vector3,\n WebGLRenderer,\n WebGLRenderTarget,\n type Material,\n type Texture,\n} from \"three\"\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration with\n * three.js: a milled case around a recessed plate, a volume knob, and 82\n * keycaps, shaded in three flat tone bands and outlined in ink traced from\n * depth and normals. Keys spring down under the real keyboard; chords are\n * recorded for a shortcut step in onboarding.\n *\n * Units are keys: 1 = one 1u keycap pitch. The desk is the XZ plane, y up,\n * +z toward the viewer.\n */\n\nexport type MechKeyboardPlatform = \"mac\" | \"pc\"\n\nexport type MechKeyboardKey = {\n /** `KeyboardEvent.code` of the key, or `\"Fn\"` */\n id: string\n /** Legend, as printed */\n label: string\n /** Shifted legend, printed after the label (`\"1 !\"`) */\n sub?: string\n /** Position from the top left of the plate and width, in key units */\n x: number\n y: number\n w: number\n /** Row: 0 is the function row */\n row: number\n}\n\nexport type MechKeyboardShortcut = {\n /** `KeyboardEvent.code`s, modifiers first */\n codes: string[]\n /** Display legends, one per key: `[\"⌘\", \"⇧\", \"K\"]` */\n keys: string[]\n /** `\"⌘⇧K\"` on Mac, `\"Ctrl+Shift+K\"` elsewhere */\n label: string\n}\n\nexport type MechKeyboardChordPhase = \"idle\" | \"live\" | \"done\"\n\nexport type MechKeyboardOptions = {\n /** Offsets in px; +y is down. Default 0. */\n x?: number\n y?: number\n /** Multiplies the fitted size. Default 1. */\n scale?: number\n /** Ink and greys only; no blue caps. Default false. */\n monochrome?: boolean\n /** Real key presses and clicks drive the keys. Default true. */\n interactive?: boolean\n /** Synthesized switch sounds. Default false. */\n sound?: boolean\n /** The keyboard rises into place and a wave runs across the keys. Default true. */\n intro?: boolean\n /** Codes to pulse. */\n highlight?: string[]\n /** Bottom-row legends and shortcut labels. Default: detected. */\n platform?: MechKeyboardPlatform\n /** Leave room at the top for the recorder. Default false. */\n recorder?: boolean\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n /** Live chord for the recorder. */\n onChord?: (codes: string[], phase: MechKeyboardChordPhase) => void\n}\n\nexport type MechKeyboardInstance = {\n setOptions: (next: Partial) => void\n /** Plays the intro again. */\n replay: () => void\n destroy: () => void\n}\n\nexport const DEFAULT_PLACEHOLDER = \"Press a shortcut\"\n\n/* ------------------------------------------------------------------ */\n/* Palette */\n/* ------------------------------------------------------------------ */\n\ntype Palette = {\n case: string\n plate: string\n /** Caps fade from the back left to the front right. */\n capBack: string\n capFront: string\n knob: string\n /** Recorder keycaps: top face and skirt */\n chip: string\n chipSide: string\n}\n\n/** Outlines, legends, and the knob's notch. */\nconst INK = \"#2B3442\"\nconst HIGHLIGHT = \"#7E9BBA\"\n\nconst FROST: Palette = {\n case: \"#E6E7E9\",\n plate: \"#8E96A3\",\n capBack: \"#AFC3D7\",\n capFront: \"#F1F2F4\",\n knob: \"#E2E4E7\",\n chip: \"#E9EEF4\",\n chipSide: \"#AFC3D7\",\n}\n\nconst MONO: Palette = {\n case: \"#F2F2F3\",\n plate: \"#A3A6AC\",\n capBack: \"#FAFAFA\",\n capFront: \"#FAFAFA\",\n knob: \"#F2F2F3\",\n chip: \"#FAFAFA\",\n chipSide: \"#CDD0D5\",\n}\n\nconst paletteFor = (monochrome: boolean) => (monochrome ? MONO : FROST)\n\n/** CSS variables the recorder keycaps read. */\nexport function recorderVars(monochrome = false) {\n const p = paletteFor(monochrome)\n return { \"--chip\": p.chip, \"--chip-side\": p.chipSide, \"--ink\": INK }\n}\n\n/* ------------------------------------------------------------------ */\n/* Layout */\n/* ------------------------------------------------------------------ */\n\nconst COLS = 16\n/** The function row, a quarter-unit gap, then five rows. */\nconst LAYOUT_D = 6.25\nconst KNOB_ID = \"AudioVolumeMute\"\n\ntype Spec = [id: string, w: number, label?: string, sub?: string]\n\nconst GAP_KEY = \"__gap\"\n\nconst gap = (w: number): Spec => [GAP_KEY, w]\nconst letters = (s: string): Spec[] => s.split(\"\").map((c) => [`Key${c}`, 1, c])\nconst fkeys = (from: number): Spec[] =>\n [0, 1, 2, 3].map((i) => [`F${from + i}`, 1, `F${from + i}`])\n\nfunction rows(platform: MechKeyboardPlatform): Spec[][] {\n const mac = platform === \"mac\"\n const bottom: Spec[] = mac\n ? [\n [\"ControlLeft\", 1.25, \"Control\"],\n [\"AltLeft\", 1.25, \"Option\"],\n [\"MetaLeft\", 1.25, \"Command\"],\n [\"Space\", 6.25, \"\"],\n [\"MetaRight\", 1, \"⌘\"],\n [\"AltRight\", 1, \"⌥\"],\n [\"Fn\", 1, \"Fn\"],\n ]\n : [\n [\"ControlLeft\", 1.25, \"Ctrl\"],\n [\"MetaLeft\", 1.25, \"Win\"],\n [\"AltLeft\", 1.25, \"Alt\"],\n [\"Space\", 6.25, \"\"],\n [\"AltRight\", 1, \"Alt\"],\n [\"Fn\", 1, \"Fn\"],\n [\"ControlRight\", 1, \"Ctrl\"],\n ]\n return [\n [\n [\"Escape\", 1, \"Esc\"],\n gap(0.25),\n ...fkeys(1),\n gap(0.25),\n ...fkeys(5),\n gap(0.25),\n ...fkeys(9),\n gap(0.25),\n [\"Delete\", 1, \"Del\"],\n ],\n [\n [\"Backquote\", 1, \"`\", \"~\"],\n [\"Digit1\", 1, \"1\", \"!\"],\n [\"Digit2\", 1, \"2\", \"@\"],\n [\"Digit3\", 1, \"3\", \"#\"],\n [\"Digit4\", 1, \"4\", \"$\"],\n [\"Digit5\", 1, \"5\", \"%\"],\n [\"Digit6\", 1, \"6\", \"^\"],\n [\"Digit7\", 1, \"7\", \"&\"],\n [\"Digit8\", 1, \"8\", \"*\"],\n [\"Digit9\", 1, \"9\", \"(\"],\n [\"Digit0\", 1, \"0\", \")\"],\n [\"Minus\", 1, \"-\", \"_\"],\n [\"Equal\", 1, \"=\", \"+\"],\n [\"Backspace\", 2, \"Backspace\"],\n [\"Home\", 1, \"Home\"],\n ],\n [\n [\"Tab\", 1.5, \"Tab\"],\n ...letters(\"QWERTYUIOP\"),\n [\"BracketLeft\", 1, \"[\", \"{\"],\n [\"BracketRight\", 1, \"]\", \"}\"],\n [\"Backslash\", 1.5, \"\\\\\", \"|\"],\n [\"PageUp\", 1, \"PgUp\"],\n ],\n [\n [\"CapsLock\", 1.75, \"Caps\"],\n ...letters(\"ASDFGHJKL\"),\n [\"Semicolon\", 1, \";\", \":\"],\n [\"Quote\", 1, \"'\", '\"'],\n [\"Enter\", 2.25, \"Enter\"],\n [\"PageDown\", 1, \"PgDn\"],\n ],\n [\n [\"ShiftLeft\", 2.25, \"Shift\"],\n ...letters(\"ZXCVBNM\"),\n [\"Comma\", 1, \",\", \"<\"],\n [\"Period\", 1, \".\", \">\"],\n [\"Slash\", 1, \"/\", \"?\"],\n [\"ShiftRight\", 1.75, \"Shift\"],\n [\"ArrowUp\", 1, \"↑\"],\n [\"End\", 1, \"End\"],\n ],\n [\n ...bottom,\n [\"ArrowLeft\", 1, \"←\"],\n [\"ArrowDown\", 1, \"↓\"],\n [\"ArrowRight\", 1, \"→\"],\n ],\n ]\n}\n\n/** The 82 keys of a 75% board, Mac or PC bottom row. */\nexport function buildLayout(platform: MechKeyboardPlatform): MechKeyboardKey[] {\n const keys: MechKeyboardKey[] = []\n rows(platform).forEach((row, r) => {\n const y = r === 0 ? 0 : r + 0.25\n let x = 0\n for (const [id, w, label = \"\", sub] of row) {\n if (id !== GAP_KEY) keys.push({ id, label, sub, x, y, w, row: r })\n x += w\n }\n })\n return keys\n}\n\n/* ------------------------------------------------------------------ */\n/* Codes, labels, shortcuts */\n/* ------------------------------------------------------------------ */\n\nexport function detectPlatform(): MechKeyboardPlatform {\n if (typeof navigator === \"undefined\") return \"pc\"\n const nav = navigator as Navigator & {\n userAgentData?: { platform?: string }\n }\n const name = nav.userAgentData?.platform || nav.platform || nav.userAgent\n return /mac|iphone|ipad|ipod/i.test(name) ? \"mac\" : \"pc\"\n}\n\nconst MODIFIER_ORDER: Record = {\n mac: [\"Control\", \"Alt\", \"Shift\", \"Meta\", \"Fn\"],\n pc: [\"Control\", \"Meta\", \"Alt\", \"Shift\", \"Fn\"],\n}\n\n/** `\"ShiftLeft\"` → `\"Shift\"`; anything else → undefined. */\nexport function modifierOf(code: string) {\n if (code === \"Fn\") return \"Fn\"\n const match = /^(Control|Alt|Shift|Meta|OS)(Left|Right)$/.exec(code)\n if (!match) return undefined\n return match[1] === \"OS\" ? \"Meta\" : match[1]\n}\n\nconst MAC_LABELS: Record = {\n Control: \"⌃\",\n Alt: \"⌥\",\n Shift: \"⇧\",\n Meta: \"⌘\",\n Fn: \"fn\",\n Escape: \"esc\",\n Tab: \"⇥\",\n CapsLock: \"⇪\",\n Enter: \"↩\",\n Backspace: \"⌫\",\n Delete: \"⌦\",\n Space: \"Space\",\n}\n\nconst PC_LABELS: Record = {\n Control: \"Ctrl\",\n Alt: \"Alt\",\n Shift: \"Shift\",\n Meta: \"Win\",\n Fn: \"Fn\",\n Escape: \"Esc\",\n Tab: \"Tab\",\n CapsLock: \"Caps\",\n Enter: \"Enter\",\n Backspace: \"Backspace\",\n Delete: \"Del\",\n Space: \"Space\",\n}\n\nconst SHARED_LABELS: Record = {\n ArrowUp: \"↑\",\n ArrowDown: \"↓\",\n ArrowLeft: \"←\",\n ArrowRight: \"→\",\n PageUp: \"PgUp\",\n PageDown: \"PgDn\",\n Home: \"Home\",\n End: \"End\",\n Insert: \"Ins\",\n Minus: \"-\",\n Equal: \"=\",\n BracketLeft: \"[\",\n BracketRight: \"]\",\n Backslash: \"\\\\\",\n IntlBackslash: \"\\\\\",\n Semicolon: \";\",\n Quote: \"'\",\n Comma: \",\",\n Period: \".\",\n Slash: \"/\",\n Backquote: \"`\",\n NumpadEnter: \"Enter\",\n AudioVolumeMute: \"Mute\",\n AudioVolumeUp: \"Vol +\",\n AudioVolumeDown: \"Vol −\",\n}\n\n/** One legend for a `KeyboardEvent.code`. */\nexport function keyLabel(code: string, platform: MechKeyboardPlatform) {\n const table = platform === \"mac\" ? MAC_LABELS : PC_LABELS\n const name = modifierOf(code) ?? code\n if (table[name]) return table[name]\n if (SHARED_LABELS[name]) return SHARED_LABELS[name]\n const match = /^(?:Key|Digit|Numpad)(.+)$/.exec(name)\n if (match) return match[1]!\n return name\n}\n\n/** Modifiers first in platform order (one per kind), then keys as pressed. */\nexport function formatShortcut(\n codes: string[],\n platform: MechKeyboardPlatform\n): MechKeyboardShortcut {\n const order = MODIFIER_ORDER[platform]\n const mods = new Map()\n const rest: string[] = []\n for (const code of codes) {\n const mod = modifierOf(code)\n if (mod) {\n if (!mods.has(mod)) mods.set(mod, code)\n } else if (!rest.includes(code)) {\n rest.push(code)\n }\n }\n const sorted = [\n ...order.filter((m) => mods.has(m)).map((m) => mods.get(m)!),\n ...rest,\n ]\n const keys = sorted.map((code) => keyLabel(code, platform))\n return {\n codes: sorted,\n keys,\n label: platform === \"mac\" ? keys.join(\"\") : keys.join(\"+\"),\n }\n}\n\nconst ALIASES: Record = {\n Insert: [\"Delete\"],\n NumpadEnter: [\"Enter\"],\n IntlBackslash: [\"Backslash\"],\n OSLeft: [\"MetaLeft\"],\n OSRight: [\"MetaRight\"],\n NumpadDecimal: [\"Period\"],\n NumpadDivide: [\"Slash\"],\n NumpadSubtract: [\"Minus\"],\n NumpadAdd: [\"Equal\"],\n AudioVolumeUp: [],\n AudioVolumeDown: [],\n}\n\n/** Which keycaps a code moves. */\nfunction capsFor(code: string): string[] {\n const pad = /^Numpad(\\d)$/.exec(code)\n if (pad) return [`Digit${pad[1]}`]\n return ALIASES[code] ?? [code]\n}\n\n/* ------------------------------------------------------------------ */\n/* Geometry */\n/* ------------------------------------------------------------------ */\n\nconst DEG = Math.PI / 180\n/** True isometric: the desk turned 45° and seen from 35.26° up. */\nconst ISO_TILT = 35.26 * DEG\nconst ISO_TURN = 45 * DEG\n/** Bezel around the plate */\nconst BEZEL = 0.48\nconst W = COLS + BEZEL * 2\nconst D = LAYOUT_D + BEZEL * 2\n/** Case height at the front; the back rises by RISE (typing angle). */\nconst CASE_H = 0.95\nconst RISE = 0.4\nconst TILT = Math.atan2(RISE, D)\n/** Bezel top down to the top of the plate */\nconst WELL = 0.5\n/** Gap around a cap in its cell, switch height under the cap, key travel */\nconst GAP = 0.035\nconst SWITCH = 0.12\nconst TRAVEL = 0.16\n/** Keycap: height, skirt taper, top edge radius, corner radius */\nconst CAP_H = 0.5\nconst CAP_TAPER = 0.07\nconst CAP_EDGE = 0.018\nconst CAP_RADIUS = 0.1\n\nfunction roundedRect(path: T, w: number, h: number, r: number) {\n const x = -w / 2\n const y = -h / 2\n path.moveTo(x + r, y)\n path.lineTo(x + w - r, y)\n path.quadraticCurveTo(x + w, y, x + w, y + r)\n path.lineTo(x + w, y + h - r)\n path.quadraticCurveTo(x + w, y + h, x + w - r, y + h)\n path.lineTo(x + r, y + h)\n path.quadraticCurveTo(x, y + h, x, y + h - r)\n path.lineTo(x, y + r)\n path.quadraticCurveTo(x, y, x + r, y)\n return path\n}\n\n/**\n * The milled frame around the well: extruded with one-segment bevels, so\n * every edge is a flat chamfer the ink can trace, then sheared into a\n * wedge.\n */\nfunction caseGeometry() {\n const chamfer = 0.05\n const outer = roundedRect(new Shape(), W - chamfer * 2, D - chamfer * 2, 0.16)\n outer.holes.push(\n roundedRect(\n new Path(),\n COLS + 0.12 + chamfer * 2,\n LAYOUT_D + 0.12 + chamfer * 2,\n 0.06\n )\n )\n const geometry = new ExtrudeGeometry(outer, {\n depth: CASE_H - chamfer * 2,\n bevelEnabled: true,\n bevelThickness: chamfer,\n bevelSize: chamfer,\n bevelSegments: 1,\n curveSegments: 8,\n })\n geometry.rotateX(-Math.PI / 2)\n geometry.translate(0, chamfer, 0)\n const pos = geometry.attributes.position!\n for (let i = 0; i < pos.count; i++) {\n const y = pos.getY(i)\n const back = Math.min(1, Math.max(0, (D / 2 - pos.getZ(i)) / D))\n pos.setY(i, y + back * RISE * Math.min(1, Math.max(0, y / CASE_H)))\n }\n geometry.computeVertexNormals()\n return geometry\n}\n\n/** Top of the bezel at depth z (0 is the middle). */\nconst bezelTop = (z: number) => CASE_H + ((D / 2 - z) / D) * RISE\n\n/** Points around a rounded rectangle, back right corner first. */\nfunction ring(hw: number, hd: number, r: number, seg: number) {\n const out: Array<[number, number]> = []\n const corners: Array<[number, number, number]> = [\n [hw - r, -(hd - r), -90],\n [hw - r, hd - r, 0],\n [-(hw - r), hd - r, 90],\n [-(hw - r), -(hd - r), 180],\n ]\n for (const [cx, cz, a0] of corners) {\n for (let i = 0; i <= seg; i++) {\n const a = (a0 + (90 * i) / seg) * DEG\n out.push([cx + Math.cos(a) * r, cz + Math.sin(a) * r])\n }\n }\n return out\n}\n\n/**\n * A keycap as one smooth surface: a tapered skirt and a crisp top edge.\n * UVs are a projection from above over the footprint, so a canvas the size\n * of the footprint prints its legend onto the top.\n */\nfunction capGeometry(w: number) {\n const fw = w - GAP * 2\n const fd = 1 - GAP * 2\n const profile: Array<[inset: number, y: number]> = []\n for (let i = 0; i <= 2; i++) {\n const t = i / 2\n profile.push([CAP_TAPER * t, (CAP_H - CAP_EDGE) * t])\n }\n for (let i = 1; i <= 3; i++) {\n const a = (i / 3) * (Math.PI / 2)\n profile.push([\n CAP_TAPER + CAP_EDGE * (1 - Math.cos(a)),\n CAP_H - CAP_EDGE + CAP_EDGE * Math.sin(a),\n ])\n }\n\n const rings: Array> = []\n for (const [inset, y] of profile) {\n const r = Math.max(0.03, CAP_RADIUS - inset * 0.35)\n rings.push(\n ring(fw / 2 - inset, fd / 2 - inset, r, 5).map(([x, z]) => [x, y, z])\n )\n }\n const top = rings[rings.length - 1]!\n for (const s of [0.5]) rings.push(top.map(([x, y, z]) => [x * s, y, z * s]))\n\n const positions: number[] = []\n const uvs: number[] = []\n for (const points of rings) {\n for (const [x, y, z] of points) {\n positions.push(x, y, z)\n uvs.push(0.5 + x / fw, 0.5 - z / fd)\n }\n }\n positions.push(0, CAP_H, 0)\n uvs.push(0.5, 0.5)\n\n const n = top.length\n const index: number[] = []\n for (let i = 0; i < rings.length - 1; i++) {\n for (let j = 0; j < n; j++) {\n const a = i * n + j\n const b = i * n + ((j + 1) % n)\n const c = (i + 1) * n + j\n const d = (i + 1) * n + ((j + 1) % n)\n index.push(a, c, b, b, c, d)\n }\n }\n const center = rings.length * n\n const last = (rings.length - 1) * n\n for (let j = 0; j < n; j++) {\n index.push(last + j, center, last + ((j + 1) % n))\n }\n\n const geometry = new BufferGeometry()\n geometry.setAttribute(\"position\", new Float32BufferAttribute(positions, 3))\n geometry.setAttribute(\"uv\", new Float32BufferAttribute(uvs, 2))\n geometry.setIndex(index)\n geometry.computeVertexNormals()\n return geometry\n}\n\n/** The volume knob, turned on a lathe. */\nfunction knobGeometry() {\n const r = 0.4\n const h = 0.62\n const points = [\n new Vector2(0, 0),\n new Vector2(r - 0.02, 0),\n new Vector2(r, 0.02),\n new Vector2(r, h - 0.03),\n new Vector2(r - 0.03, h),\n new Vector2(0, h),\n ]\n return new LatheGeometry(points, 64)\n}\n\n/* ------------------------------------------------------------------ */\n/* Textures */\n/* ------------------------------------------------------------------ */\n\nfunction canvas(w: number, h: number) {\n const el = document.createElement(\"canvas\")\n el.width = w\n el.height = h\n return [el, el.getContext(\"2d\")!] as const\n}\n\n/** Three flat tone bands. */\nfunction rampTexture() {\n const texture = new DataTexture(\n new Uint8Array([120, 196, 255]),\n 3,\n 1,\n RedFormat\n )\n texture.minFilter = NearestFilter\n texture.magFilter = NearestFilter\n texture.generateMipmaps = false\n texture.needsUpdate = true\n return texture\n}\n\nconst LEGEND_PX = 256\n\n/** Arrow legends, as the angle of a right-pointing arrow. */\nconst ARROWS: Record = {\n \"→\": 0,\n \"↓\": Math.PI / 2,\n \"←\": Math.PI,\n \"↑\": -Math.PI / 2,\n}\n\n/** The cap's color with its legend in ink, like the outlines. */\nfunction capTexture(key: MechKeyboardKey, color: string, font: string) {\n const fw = key.w - GAP * 2\n const fd = 1 - GAP * 2\n const [el, ctx] = canvas(\n Math.round(fw * LEGEND_PX),\n Math.round(fd * LEGEND_PX)\n )\n ctx.fillStyle = color\n ctx.fillRect(0, 0, el.width, el.height)\n ctx.fillStyle = INK\n ctx.strokeStyle = INK\n const cx = el.width / 2\n const cy = el.height * 0.47\n const arrow = ARROWS[key.label]\n if (arrow !== undefined) {\n // Drawn, not typed: plenty of UI fonts have no ← or →.\n const len = 0.17 * LEGEND_PX\n ctx.save()\n ctx.translate(cx, cy)\n ctx.rotate(arrow)\n ctx.lineWidth = 0.02 * LEGEND_PX\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n ctx.beginPath()\n ctx.moveTo(-len / 2, 0)\n ctx.lineTo(len / 2, 0)\n ctx.moveTo(len / 2 - len * 0.32, -len * 0.3)\n ctx.lineTo(len / 2, 0)\n ctx.lineTo(len / 2 - len * 0.32, len * 0.3)\n ctx.stroke()\n ctx.restore()\n } else {\n const single = [...key.label].length === 1 && !key.sub\n const text = key.sub\n ? `${key.label} ${key.sub}`\n : single\n ? key.label\n : key.label.toUpperCase()\n const size = single ? 0.22 : key.sub ? 0.17 : 0.125\n ctx.font = `600 ${size * LEGEND_PX}px ${font}`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n if (!single) ctx.letterSpacing = `${0.014 * LEGEND_PX}px`\n ctx.fillText(text, cx, cy)\n }\n const texture = new CanvasTexture(el)\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = 8\n return texture\n}\n\n/* ------------------------------------------------------------------ */\n/* Sound */\n/* ------------------------------------------------------------------ */\n\nfunction createThock() {\n let audio: AudioContext | null = null\n let noise: AudioBuffer | null = null\n let master: GainNode | null = null\n return {\n play(code: string, down: boolean) {\n try {\n if (!audio) {\n const Ctx =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext\n audio = new Ctx()\n master = audio.createGain()\n master.gain.value = 0.55\n master.connect(audio.destination)\n noise = audio.createBuffer(\n 1,\n Math.floor(audio.sampleRate * 0.06),\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()\n const t = audio.currentTime\n const big =\n code === \"Space\" ||\n code === \"Enter\" ||\n code === \"Backspace\" ||\n code.startsWith(\"Shift\")\n const vary = 0.93 + Math.random() * 0.14\n\n const body = audio.createOscillator()\n body.type = \"sine\"\n const f = 132 * (big ? 0.72 : 1) * (down ? 1 : 1.3) * vary\n body.frequency.setValueAtTime(f * 1.9, t)\n body.frequency.exponentialRampToValueAtTime(f, t + 0.03)\n const bodyGain = audio.createGain()\n bodyGain.gain.setValueAtTime(down ? 0.32 : 0.1, t)\n bodyGain.gain.exponentialRampToValueAtTime(\n 0.0001,\n t + (down ? 0.1 : 0.05)\n )\n body.connect(bodyGain).connect(master!)\n body.start(t)\n body.stop(t + 0.12)\n\n const hit = audio.createBufferSource()\n hit.buffer = noise\n const band = audio.createBiquadFilter()\n band.type = \"bandpass\"\n band.frequency.value = 2900 * (down ? 1 : 1.25) * vary\n band.Q.value = 1.1\n const hitGain = audio.createGain()\n hitGain.gain.setValueAtTime(down ? 0.5 : 0.22, t)\n hitGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.035)\n hit.connect(band).connect(hitGain).connect(master!)\n hit.start(t)\n hit.stop(t + 0.06)\n } catch {\n /* no audio: stay silent */\n }\n },\n close() {\n void audio?.close()\n },\n }\n}\n\nfunction isEditable(target: EventTarget | null) {\n if (!(target instanceof HTMLElement)) return false\n if (target.isContentEditable) return true\n const tag = target.tagName\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return true\n if (tag !== \"INPUT\") return false\n const type = (target as HTMLInputElement).type\n return ![\n \"checkbox\",\n \"radio\",\n \"button\",\n \"range\",\n \"color\",\n \"submit\",\n \"reset\",\n ].includes(type)\n}\n\n/* ------------------------------------------------------------------ */\n/* Engine */\n/* ------------------------------------------------------------------ */\n\ntype Resolved = Required<\n Omit\n> &\n Pick & {\n highlight: string[]\n }\n\nfunction resolve(options: MechKeyboardOptions): Resolved {\n const num = (v: number | undefined, d: number) =>\n typeof v === \"number\" && Number.isFinite(v) ? v : d\n return {\n x: num(options.x, 0),\n y: num(options.y, 0),\n scale: Math.max(0.05, num(options.scale, 1)),\n monochrome: options.monochrome ?? false,\n interactive: options.interactive ?? true,\n sound: options.sound ?? false,\n intro: options.intro ?? true,\n highlight: options.highlight ?? [],\n platform:\n options.platform === \"mac\" || options.platform === \"pc\"\n ? options.platform\n : detectPlatform(),\n recorder: options.recorder ?? false,\n onKey: options.onKey,\n onShortcut: options.onShortcut,\n onChord: options.onChord,\n }\n}\n\ntype Cap = {\n id: string\n x: number\n y: number\n holder: Group\n /** Rest height of the holder */\n base: number\n /** How far it goes down */\n travel: number\n material: MeshToonMaterial\n pos: number\n vel: number\n target: number\n}\n\nconst NEAR = 0.1\nconst FAR = 200\n\n/**\n * Mounts a canvas in `stage` and listens on `root`\n * (focusable; pointer and keys). Returns a no-op instance without WebGL.\n */\nexport function createMechKeyboard(\n root: HTMLElement,\n stage: HTMLElement,\n initial: MechKeyboardOptions = {}\n): MechKeyboardInstance {\n let opts = resolve(initial)\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({ alpha: true, antialias: true })\n } catch {\n return { setOptions() {}, replay() {}, destroy() {} }\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const el = renderer.domElement\n el.style.display = \"block\"\n el.style.width = \"100%\"\n el.style.height = \"100%\"\n stage.appendChild(el)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduced = mqReduce.matches\n let destroyed = false\n const disposables: Array = []\n const keep = (item: T) => {\n disposables.push(item)\n return item\n }\n\n /* ---------- scene ---------- */\n\n const scene = new Scene()\n const camera = new OrthographicCamera(-1, 1, 1, -1, NEAR, FAR)\n\n // desk: tipped toward the camera. board: turned 45° on the desk.\n const desk = new Group()\n desk.rotation.x = ISO_TILT\n scene.add(desk)\n const board = new Group()\n board.rotation.y = ISO_TURN\n board.position.y = -(CASE_H + RISE / 2) / 2\n desk.add(board)\n\n // Light fixed to the view, the illustrator's convention: tops lightest,\n // left faces middle, right faces darkest.\n const key = new DirectionalLight(0xffffff, 1.9)\n key.position.set(-3, 6, 4)\n scene.add(key, key.target, new AmbientLight(0xffffff, 1.1))\n\n // A flat, hard-edged shadow, offset down and to the right.\n const shadow = new Mesh(\n keep(\n new ShapeGeometry(roundedRect(new Shape(), W, D, 0.16), 8).rotateX(\n -Math.PI / 2\n )\n ),\n keep(\n new MeshBasicMaterial({\n color: INK,\n transparent: true,\n opacity: 0.1,\n depthWrite: false,\n })\n )\n )\n shadow.rotation.y = ISO_TURN\n shadow.position.set(0.55, board.position.y + 0.003, 0.75)\n desk.add(shadow)\n\n const ramp = keep(rampTexture())\n const caseGeo = keep(caseGeometry())\n const knobGeo = keep(knobGeometry())\n const capGeos = new Map()\n const capGeo = (w: number) => {\n let g = capGeos.get(w)\n if (!g) capGeos.set(w, (g = keep(capGeometry(w))))\n return g\n }\n\n /* ---------- the board, rebuilt per palette, platform, and font ---------- */\n\n let built: {\n group: Group\n disposables: Array\n knob: Mesh\n pickable: Mesh[]\n } | null = null\n let builtFor = \"\"\n const caps = new Map()\n let knobAngle = 0\n\n const fontFamily = () =>\n getComputedStyle(root).fontFamily || \"ui-sans-serif, system-ui, sans-serif\"\n const plateX = (x: number) => -COLS / 2 + x\n const plateZ = (y: number) => -LAYOUT_D / 2 + y\n\n const build = () => {\n const font = fontFamily()\n const stamp = `${opts.monochrome}|${opts.platform}|${font}`\n if (stamp === builtFor) return\n builtFor = stamp\n const palette = paletteFor(opts.monochrome)\n const own: Array = []\n const take = (item: T) => {\n own.push(item)\n return item\n }\n const toon = (color: Color | string, map: Texture | null = null) =>\n take(\n new MeshToonMaterial({\n color,\n map,\n gradientMap: ramp,\n emissive: new Color(HIGHLIGHT),\n emissiveIntensity: 0,\n })\n )\n\n const group = new Group()\n const caseMat = toon(palette.case)\n group.add(new Mesh(caseGeo, caseMat))\n const bottom = new Mesh(\n take(new BoxGeometry(W - 0.4, 0.06, D - 0.4)),\n caseMat\n )\n bottom.position.y = 0.03\n group.add(bottom)\n\n // Plate and keys ride the tipped top.\n const deck = new Group()\n deck.position.y = bezelTop(0) - WELL\n deck.rotation.x = TILT\n group.add(deck)\n\n const plate = new Mesh(\n take(new BoxGeometry(COLS + 0.2, 0.08, LAYOUT_D + 0.2)),\n toon(palette.plate)\n )\n plate.position.y = -0.04\n deck.add(plate)\n\n const prev = new Map(caps)\n caps.clear()\n\n const knobMat = toon(palette.knob)\n const knobHolder = new Group()\n knobHolder.position.set(plateX(15.5), 0.08, plateZ(0.5))\n deck.add(knobHolder)\n const knob = new Mesh(knobGeo, knobMat)\n knob.userData.id = KNOB_ID\n knob.rotation.y = knobAngle\n knobHolder.add(knob)\n const notch = new Mesh(\n take(new BoxGeometry(0.05, 0.012, 0.2)),\n take(new MeshBasicMaterial({ color: INK }))\n )\n notch.position.set(0, 0.622, -0.19)\n knob.add(notch)\n const pickable: Mesh[] = [knob]\n\n const next: Cap[] = [\n {\n id: KNOB_ID,\n x: 15,\n y: 0,\n holder: knobHolder,\n base: 0.08,\n travel: 0.06,\n material: knobMat,\n pos: 0,\n vel: 0,\n target: 0,\n },\n ]\n\n const from = new Color(palette.capBack)\n const to = new Color(palette.capFront)\n for (const k of buildLayout(opts.platform)) {\n const cx = plateX(k.x + k.w / 2)\n const cz = plateZ(k.y + 0.5)\n const holder = new Group()\n holder.position.set(cx, SWITCH, cz)\n deck.add(holder)\n\n // The back rows carry the color; it washes out toward the front right.\n const t = Math.min(\n 1,\n Math.max(\n 0,\n (k.y / 5.25) * 0.82 + ((k.x + k.w / 2) / COLS) * 0.26 - 0.08\n )\n )\n const color = from.clone().lerp(to, t)\n const map = k.label ? take(capTexture(k, color.getStyle(), font)) : null\n const material = toon(map ? \"#ffffff\" : color, map)\n const mesh = new Mesh(capGeo(k.w), material)\n mesh.userData.id = k.id\n holder.add(mesh)\n pickable.push(mesh)\n next.push({\n id: k.id,\n x: k.x,\n y: k.y,\n holder,\n base: SWITCH,\n travel: TRAVEL,\n material,\n pos: 0,\n vel: 0,\n target: 0,\n })\n }\n\n // Keys that were down stay down across a rebuild.\n for (const cap of next) {\n const old = prev.get(cap.id)\n if (old) {\n cap.pos = old.pos\n cap.target = old.target\n }\n cap.holder.position.y = cap.base + cap.pos\n caps.set(cap.id, cap)\n }\n\n board.add(group)\n if (built) {\n board.remove(built.group)\n for (const item of built.disposables) item.dispose()\n }\n built = { group, disposables: own, knob, pickable }\n paintHighlights()\n }\n\n /* ---------- ink: edges from depth and normals ---------- */\n\n const color = new WebGLRenderTarget(1, 1, { type: HalfFloatType, samples: 4 })\n const normal = new WebGLRenderTarget(1, 1, { type: HalfFloatType })\n normal.depthTexture = new DepthTexture(1, 1)\n const normalMat = keep(new MeshNormalMaterial())\n const quad = new Mesh(\n keep(new PlaneGeometry(2, 2)),\n keep(\n new ShaderMaterial({\n uniforms: {\n tColor: { value: color.texture },\n tNormal: { value: normal.texture },\n tDepth: { value: normal.depthTexture },\n texel: { value: new Vector2() },\n ink: { value: new Color(INK) },\n depthRange: { value: FAR - NEAR },\n },\n vertexShader: INK_VERTEX,\n fragmentShader: INK_FRAGMENT,\n depthTest: false,\n depthWrite: false,\n blending: NoBlending,\n })\n )\n )\n const post = new Scene()\n post.add(quad)\n const postCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)\n\n const render = () => {\n renderer.setRenderTarget(color)\n renderer.setClearColor(0x000000, 0)\n renderer.render(scene, camera)\n\n shadow.visible = false\n scene.overrideMaterial = normalMat\n renderer.setRenderTarget(normal)\n renderer.setClearColor(0x8080ff, 1)\n renderer.render(scene, camera)\n scene.overrideMaterial = null\n shadow.visible = true\n\n renderer.setClearColor(0x000000, 0)\n renderer.setRenderTarget(null)\n renderer.render(post, postCamera)\n }\n\n /* ---------- fit and offset ---------- */\n\n let width = 0\n let height = 0\n const current = { x: 0, y: 0 }\n let introFrom: { x: number; y: number } | null = null\n let introStart = 0\n const INTRO_MS = 1300\n\n const corners: Vector3[] = []\n for (const x of [-W / 2, W / 2])\n for (const y of [0, CASE_H + RISE + 0.1])\n for (const z of [-D / 2, D / 2]) corners.push(new Vector3(x, y, z))\n const scratch = new Vector3()\n\n /** Frame the board exactly: measure its projected box straight on. */\n const fit = () => {\n if (!width || !height) return\n desk.position.set(0, 0, 0)\n desk.updateMatrixWorld(true)\n let minX = Infinity\n let maxX = -Infinity\n let minY = Infinity\n let maxY = -Infinity\n for (const c of corners) {\n scratch.copy(c).applyMatrix4(board.matrixWorld)\n minX = Math.min(minX, scratch.x)\n maxX = Math.max(maxX, scratch.x)\n minY = Math.min(minY, scratch.y)\n maxY = Math.max(maxY, scratch.y)\n }\n const aspect = width / height\n const room = opts.recorder ? 0.66 : 0.82\n const half =\n Math.max((maxY - minY) / 2 / room, (maxX - minX) / 2 / 0.88 / aspect) /\n opts.scale\n camera.left = -half * aspect\n camera.right = half * aspect\n camera.top = half\n camera.bottom = -half\n camera.position.set((minX + maxX) / 2, (minY + maxY) / 2, FAR / 2)\n camera.lookAt(camera.position.x, camera.position.y, 0)\n camera.updateProjectionMatrix()\n place()\n }\n\n function place() {\n const perPx = (camera.top - camera.bottom) / (height || 1)\n const drop = opts.recorder ? height * 0.04 : 0\n desk.position.set(current.x * perPx, -(current.y + drop) * perPx, 0)\n }\n\n const resize = () => {\n const w = stage.clientWidth\n const h = stage.clientHeight\n if (!w || !h || (w === width && h === height)) return false\n width = w\n height = h\n renderer.setSize(w, h, false)\n const pr = renderer.getPixelRatio()\n const pw = Math.max(1, Math.round(w * pr))\n const ph = Math.max(1, Math.round(h * pr))\n // Normals and depth at twice the resolution on 1x screens, so the\n // four-tap average in the ink pass has subpixels to antialias with.\n const s = pr < 2 ? 2 : 1\n color.setSize(pw, ph)\n normal.setSize(pw * s, ph * s)\n quad.material.uniforms.texel!.value.set(1 / pw, 1 / ph)\n fit()\n return true\n }\n\n /* ---------- loop ---------- */\n\n let raf = 0\n let last = 0\n let onScreen = true\n let pulse = 0\n let knobGoal = 0\n\n const wake = () => {\n if (raf || destroyed || !onScreen || document.hidden) return\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n\n const frame = (now: number) => {\n raf = 0\n if (destroyed) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n let busy = false\n\n // Offset: the intro rise, then exponential smoothing toward the props.\n if (introFrom) {\n const t = Math.min(1, (now - introStart) / INTRO_MS)\n const e = 1 - Math.pow(1 - t, 4)\n current.x = introFrom.x + (opts.x - introFrom.x) * e\n current.y = introFrom.y + (opts.y - introFrom.y) * e\n if (t >= 1) introFrom = null\n busy = true\n } else {\n const a = reduced ? 1 : 1 - Math.exp(-dt * 7)\n for (const k of [\"x\", \"y\"] as const) {\n const delta = opts[k] - current[k]\n if (Math.abs(delta) > 0.05) {\n current[k] += delta * a\n busy = true\n } else {\n current[k] = opts[k]\n }\n }\n }\n place()\n\n // Keys: a stiff spring down, a bouncier one back up, in fixed 4 ms\n // substeps (one Euler step this stiff blows up on a slow frame).\n const steps = Math.max(1, Math.ceil(dt / 0.004))\n const h = dt / steps\n for (const cap of caps.values()) {\n if (cap.pos === cap.target && cap.vel === 0) continue\n const down = cap.target < cap.pos\n const k = down ? 2600 : 900\n const c = down ? 80 : 26\n for (let i = 0; i < steps; i++) {\n cap.vel += (-k * (cap.pos - cap.target) - c * cap.vel) * h\n cap.pos += cap.vel * h\n }\n if (\n reduced ||\n (Math.abs(cap.pos - cap.target) < 0.0005 && Math.abs(cap.vel) < 0.01)\n ) {\n cap.pos = cap.target\n cap.vel = 0\n } else {\n busy = true\n }\n cap.holder.position.y = cap.base + cap.pos\n }\n\n const turn = knobGoal - knobAngle\n if (Math.abs(turn) > 0.0005) {\n knobAngle += turn * (reduced ? 1 : 1 - Math.exp(-dt * 14))\n if (built) built.knob.rotation.y = knobAngle\n busy = true\n }\n\n if (opts.highlight.length) {\n pulse += dt\n const glow = reduced ? 0.3 : 0.2 + 0.16 * Math.sin(pulse * 4)\n for (const id of opts.highlight) {\n const cap = caps.get(id)\n if (cap) cap.material.emissiveIntensity = glow\n }\n if (!reduced) busy = true\n }\n\n render()\n if (busy) wake()\n }\n\n function paintHighlights() {\n const set = new Set(opts.highlight)\n for (const [id, cap] of caps) {\n cap.material.emissiveIntensity = set.has(id) ? 0.3 : 0\n }\n }\n\n /* ---------- keys ---------- */\n\n const held = new Set()\n const latched = new Set()\n let chord: string[] = []\n const thock = createThock()\n\n const capFor = (id: string) => {\n const cap = caps.get(id)\n if (cap || !/(Left|Right)$/.test(id)) return cap\n const twin = id.endsWith(\"Left\")\n ? id.replace(/Left$/, \"Right\")\n : id.replace(/Right$/, \"Left\")\n return caps.get(twin)\n }\n\n const paint = () => {\n const down = new Set()\n for (const code of [...held, ...latched]) {\n for (const id of capsFor(code)) {\n const cap = capFor(id)\n if (cap) down.add(cap)\n }\n }\n for (const cap of caps.values()) {\n cap.target = down.has(cap) ? -cap.travel : 0\n }\n wake()\n }\n\n const sound = (code: string, down: boolean) => {\n if (opts.sound) thock.play(code, down)\n }\n\n const press = (code: string) => {\n if (held.has(code)) return\n if (code === \"AudioVolumeUp\") knobGoal -= 0.35\n if (code === \"AudioVolumeDown\") knobGoal += 0.35\n if (held.size === 0 && latched.size === 0) chord = []\n held.add(code)\n if (!chord.includes(code)) chord.push(code)\n paint()\n sound(code, true)\n opts.onKey?.(code, true)\n opts.onChord?.([...chord], \"live\")\n }\n\n const commit = () => {\n if (held.size || latched.size || chord.length === 0) return\n const done = chord\n chord = []\n opts.onChord?.(done, \"done\")\n opts.onShortcut?.(formatShortcut(done, opts.platform))\n }\n\n const release = (code: string) => {\n if (!held.delete(code)) return\n paint()\n sound(code, false)\n opts.onKey?.(code, false)\n commit()\n }\n\n const releaseAll = () => {\n const codes = [...held]\n held.clear()\n latched.clear()\n paint()\n for (const code of codes) opts.onKey?.(code, false)\n commit()\n }\n\n const timers = new Set>()\n const later = (fn: () => void, ms: number) => {\n const id = setTimeout(() => {\n timers.delete(id)\n if (!destroyed) fn()\n }, ms)\n timers.add(id)\n }\n\n // The real OS, not the `platform` legends option: it decides which\n // keyboard events arrive.\n const onMac = detectPlatform() === \"mac\"\n\n const focused = () =>\n document.activeElement === root || root.contains(document.activeElement)\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!opts.interactive || !event.code) return\n if (isEditable(event.target) && !root.contains(event.target as Node)) return\n if (focused() && event.code !== \"Tab\") event.preventDefault()\n if (event.repeat) return\n press(event.code)\n // macOS fires one event per Caps Lock toggle; never leave it stuck.\n // Elsewhere Caps Lock sends a normal down and up, so it can be held.\n if (event.code === \"CapsLock\" && onMac) {\n later(() => release(\"CapsLock\"), 160)\n }\n }\n\n const onKeyUp = (event: KeyboardEvent) => {\n if (!event.code) return\n if (event.code === \"CapsLock\" && !held.has(\"CapsLock\")) {\n if (!opts.interactive) return\n press(\"CapsLock\")\n later(() => release(\"CapsLock\"), 120)\n return\n }\n // While ⌘ is held macOS swallows other keyups; release them with it.\n if (modifierOf(event.code) === \"Meta\") {\n for (const code of [...held]) if (!modifierOf(code)) release(code)\n }\n release(event.code)\n }\n\n /* Pointer: modifiers latch, a non-modifier fires the chord. */\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const pick = (event: { clientX: number; clientY: number }) => {\n if (!built) return null\n const r = el.getBoundingClientRect()\n if (!r.width || !r.height) return null\n ndc.set(\n ((event.clientX - r.left) / r.width) * 2 - 1,\n -((event.clientY - r.top) / r.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n const hit = raycaster.intersectObjects(built.pickable, false)[0]\n return (hit?.object.userData.id as string | undefined) ?? null\n }\n\n /** Keys held down by each pointer, so two fingers release independently. */\n const pointers = new Map()\n\n const onPointerDown = (event: PointerEvent) => {\n if (!opts.interactive || event.button !== 0) return\n const code = pick(event)\n if (!code) return\n event.preventDefault()\n root.focus({ preventScroll: true })\n if (modifierOf(code)) {\n if (latched.has(code)) {\n latched.delete(code)\n chord = chord.filter((c) => c !== code)\n paint()\n opts.onChord?.([...chord], chord.length ? \"live\" : \"idle\")\n } else {\n if (held.size === 0 && latched.size === 0) chord = []\n latched.add(code)\n if (!chord.includes(code)) chord.push(code)\n paint()\n sound(code, true)\n opts.onChord?.([...chord], \"live\")\n }\n return\n }\n pointers.set(event.pointerId, code)\n press(code)\n }\n\n const onPointerUp = (event: PointerEvent) => {\n const code = pointers.get(event.pointerId)\n if (!code) return\n pointers.delete(event.pointerId)\n if (![...pointers.values()].includes(code)) {\n held.delete(code)\n sound(code, false)\n opts.onKey?.(code, false)\n }\n // Latched modifiers belong to the chord until the last finger lifts.\n if (pointers.size === 0) latched.clear()\n paint()\n commit()\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!opts.interactive || event.pointerType !== \"mouse\") return\n el.style.cursor = pick(event) ? \"pointer\" : \"\"\n }\n\n /* The knob turns under the wheel, like the real one. */\n const onWheel = (event: WheelEvent) => {\n if (!opts.interactive || pick(event) !== KNOB_ID) return\n event.preventDefault()\n knobGoal += Math.sign(event.deltaY) * 0.26\n wake()\n }\n\n const onBlur = () => {\n if (held.size || latched.size) releaseAll()\n }\n\n window.addEventListener(\"keydown\", onKeyDown)\n window.addEventListener(\"keyup\", onKeyUp)\n window.addEventListener(\"blur\", onBlur)\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"pointermove\", onPointerMove)\n root.addEventListener(\"wheel\", onWheel, { passive: false })\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onPointerUp)\n\n /* ---------- lifecycle ---------- */\n\n const ro = new ResizeObserver(() => {\n if (!resize() || destroyed) return\n // Resizing cleared the canvas; draw the current frame now.\n render()\n wake()\n })\n ro.observe(stage)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(stage)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => {\n reduced = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n // Legends are printed from a canvas; print them again once fonts land.\n void document.fonts?.ready.then(() => {\n if (destroyed) return\n builtFor = \"\"\n build()\n wake()\n })\n\n const wave = () => {\n for (const cap of caps.values()) {\n const delay = 380 + cap.x * 36 + cap.y * 24\n later(() => {\n if (cap.target !== 0) return\n cap.target = -cap.travel * 0.8\n wake()\n later(paint, 110)\n }, delay)\n }\n }\n\n const intro = () => {\n stage.style.transition = \"opacity 800ms ease-out\"\n if (!opts.intro || reduced) {\n current.x = opts.x\n current.y = opts.y\n stage.style.opacity = \"1\"\n wake()\n return\n }\n introFrom = { x: opts.x, y: opts.y + 60 }\n Object.assign(current, introFrom)\n introStart = performance.now()\n stage.style.opacity = \"0\"\n requestAnimationFrame(() => {\n if (!destroyed) stage.style.opacity = \"1\"\n })\n wave()\n wake()\n }\n\n build()\n resize()\n intro()\n\n return {\n setOptions(next) {\n opts = resolve({ ...opts, ...next })\n if (!opts.interactive && (held.size || latched.size)) releaseAll()\n build()\n fit()\n paintHighlights()\n wake()\n },\n replay() {\n intro()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n for (const id of timers) clearTimeout(id)\n timers.clear()\n ro.disconnect()\n io.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"keydown\", onKeyDown)\n window.removeEventListener(\"keyup\", onKeyUp)\n window.removeEventListener(\"blur\", onBlur)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"wheel\", onWheel)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onPointerUp)\n thock.close()\n if (built) for (const item of built.disposables) item.dispose()\n for (const item of disposables) item.dispose()\n color.dispose()\n normal.depthTexture?.dispose()\n normal.dispose()\n renderer.dispose()\n // dispose() keeps the context until garbage collection, and browsers\n // cap live contexts; give it back now.\n renderer.forceContextLoss()\n el.remove()\n },\n }\n}\n\nconst INK_VERTEX = /* glsl */ `\nvarying vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n`\n\n/**\n * Ink where depth jumps (silhouettes, caps over the plate) or the surface\n * turns sharply (case chamfers, cap edges). Four subpixel taps averaged,\n * so the lines come out antialiased.\n */\nconst INK_FRAGMENT = /* glsl */ `\nuniform sampler2D tColor;\nuniform sampler2D tNormal;\nuniform sampler2D tDepth;\nuniform vec2 texel;\nuniform vec3 ink;\nuniform float depthRange;\nvarying vec2 vUv;\n\nfloat edgeAt(vec2 uv) {\n float d0 = texture2D(tDepth, uv).r;\n vec3 n0 = texture2D(tNormal, uv).rgb * 2.0 - 1.0;\n float e = 0.0;\n vec2 dirs[4];\n dirs[0] = vec2(1.0, 0.0);\n dirs[1] = vec2(-1.0, 0.0);\n dirs[2] = vec2(0.0, 1.0);\n dirs[3] = vec2(0.0, -1.0);\n for (int i = 0; i < 4; i++) {\n vec2 q = uv + dirs[i] * texel * 0.85;\n float dd = abs(texture2D(tDepth, q).r - d0) * depthRange;\n vec3 n1 = texture2D(tNormal, q).rgb * 2.0 - 1.0;\n e = max(e, smoothstep(0.07, 0.14, dd));\n e = max(e, smoothstep(0.22, 0.4, 1.0 - dot(n0, n1)));\n }\n return e;\n}\n\nvoid main() {\n vec4 color = texture2D(tColor, vUv);\n vec2 s = texel * 0.25;\n float e = (\n edgeAt(vUv + vec2(-s.x, -s.y)) +\n edgeAt(vUv + vec2(s.x, -s.y)) +\n edgeAt(vUv + vec2(-s.x, s.y)) +\n edgeAt(vUv + vec2(s.x, s.y))\n ) * 0.25;\n gl_FragColor = mix(color, vec4(ink, 1.0), e);\n #include \n}\n`\n\n/** Tailwind for the recorder. */\nexport const CLASSES = {\n root: \"relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n stage: \"absolute inset-0 opacity-0\",\n recorder:\n \"pointer-events-none absolute inset-x-0 top-[6%] z-10 flex flex-col items-center gap-3 px-4\",\n chips: \"flex min-h-12 flex-wrap items-center justify-center gap-2\",\n /**\n * An illustrated keycap: an ink outline around a darker skirt, with the\n * top face sitting on it. Held keys sink into the skirt.\n */\n chip: \"group/chip relative inline-flex h-12 min-w-12 flex-col rounded-[9px] border-[1.5px] border-(--ink) bg-(--chip-side) pb-[6px] font-sans transition-[padding,translate,opacity,scale] duration-200 ease-[cubic-bezier(0.3,1.5,0.5,1)] starting:-translate-y-1.5 starting:scale-90 starting:opacity-0 data-live:translate-y-[3px] data-live:pb-[3px]\",\n chipTop:\n \"flex flex-1 items-center justify-center rounded-[7px] border-b-[1.5px] border-(--ink) bg-(--chip) px-3 text-sm leading-none font-semibold text-(--ink)\",\n plus: \"text-sm font-semibold text-muted-foreground\",\n placeholder: \"text-sm text-muted-foreground\",\n status:\n \"flex h-4 items-center gap-1.5 text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase\",\n dot: \"size-1.5 animate-pulse rounded-full bg-red-500\",\n check: \"size-3\",\n}\n\nexport type MechKeyboardProps = {\n /** Horizontal offset, px. */\n x?: number\n /** Vertical offset, px. */\n y?: number\n /** Multiplies the fitted size. */\n scale?: number\n /** Ink and greys only; no blue caps. */\n monochrome?: boolean\n /** Real key presses and clicks press the keys. */\n interactive?: boolean\n /** Shows the shortcut being pressed above the keyboard. */\n recorder?: boolean\n /** Recorder text before the first key. */\n placeholder?: string\n /** `KeyboardEvent.code`s to pulse, e.g. `[\"MetaLeft\", \"KeyK\"]`. */\n highlight?: string[]\n /** Bottom-row legends. `\"auto\"` detects Mac. */\n platform?: MechKeyboardPlatform | \"auto\"\n /** Synthesized switch sounds. */\n sound?: boolean\n /** Rise into place with a wave across the keys on mount. */\n intro?: boolean\n /** Every key down and up. */\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n className?: string\n}\n\nconst noop = () => () => {}\n\nfunction Check() {\n return (\n \n \n \n )\n}\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration in\n * three.js: flat tone bands, ink outlines, a volume knob. Keys spring down\n * under your keyboard (or a click; modifiers latch), and the recorder above\n * spells out the shortcut in little illustrated keycaps, onboarding-style.\n * Honors `prefers-reduced-motion`.\n */\nexport function MechKeyboard({\n x = 0,\n y = 0,\n scale = 1,\n monochrome = false,\n interactive = true,\n recorder = true,\n placeholder = DEFAULT_PLACEHOLDER,\n highlight,\n platform = \"auto\",\n sound = false,\n intro = true,\n onKey,\n onShortcut,\n className,\n}: MechKeyboardProps) {\n const rootRef = useRef(null)\n const stageRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacks = useRef({ onKey, onShortcut })\n const detected = useSyncExternalStore(\n noop,\n detectPlatform,\n (): MechKeyboardPlatform => \"pc\"\n )\n const [chord, setChord] = useState<{\n codes: string[]\n phase: MechKeyboardChordPhase\n }>({ codes: [], phase: \"idle\" })\n\n const resolvedPlatform = platform === \"auto\" ? detected : platform\n const shortcut = formatShortcut(chord.codes, resolvedPlatform)\n const highlightKey = (highlight ?? []).join(\" \")\n\n useEffect(() => {\n callbacks.current = { onKey, onShortcut }\n }, [onKey, onShortcut])\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n if (!root || !stage) return\n instanceRef.current = createMechKeyboard(root, stage, {\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight,\n platform: resolvedPlatform,\n onKey: (code, pressed) => callbacks.current.onKey?.(code, pressed),\n onShortcut: (s) => callbacks.current.onShortcut?.(s),\n onChord: (codes, phase) => setChord({ codes, phase }),\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 x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight: highlightKey ? highlightKey.split(\" \") : [],\n platform: resolvedPlatform,\n })\n }, [\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlightKey,\n resolvedPlatform,\n ])\n\n return (\n \n
\n {recorder ? (\n
\n
\n {shortcut.keys.length === 0 ? (\n {placeholder}\n ) : (\n shortcut.keys.map((key, i) => (\n \n {i > 0 ? + : null}\n \n {key}\n \n \n ))\n )}\n
\n
\n {chord.phase === \"done\" ? (\n <>\n \n Shortcut saved\n \n ) : chord.phase === \"live\" ? (\n <>\n \n Recording\n \n ) : (\n \"Listening\"\n )}\n
\n
\n ) : null}\n
\n )\n}\n", "type": "registry:ui", "target": "components/ui/mech-keyboard.tsx" } diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json deleted file mode 100644 index a932207..0000000 --- a/public/r/phosphor-score-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$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 \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/radiant-lines-svelte.json b/public/r/radiant-lines-svelte.json deleted file mode 100644 index a05727c..0000000 --- a/public/r/radiant-lines-svelte.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "radiant-lines-svelte", - "title": "Radiant Lines", - "description": "A hyperspace starfield background: colored streaks radiate from the center, with warp speed driven by scroll velocity.", - "dependencies": [], - "files": [ - { - "path": "registry/radiant-lines/radiant-lines.svelte", - "content": "\n\n\n\n\n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/radiant-lines.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/registry.json b/public/r/registry.json index 15718db..deb37c8 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -16,19 +16,6 @@ ], "type": "registry:ui" }, - { - "name": "ascii-fluid-svelte", - "title": "ASCII Fluid", - "description": "Mouse-trail WebGL fluid background quantized to a clean ASCII brightness ramp - zero deps, theme-aware.", - "files": [ - { - "path": "registry/ascii-fluid/ascii-fluid.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/ascii-fluid.svelte" - } - ], - "type": "registry:ui" - }, { "name": "ascii-logo", "title": "ASCII Logo", @@ -42,19 +29,6 @@ ], "type": "registry:ui" }, - { - "name": "ascii-logo-svelte", - "title": "ASCII Logo", - "description": "Interactive ASCII wordmark: glyphs shove away from the cursor, then click-cycle through scatter, gravity drop, and reassemble. Zero deps.", - "files": [ - { - "path": "registry/ascii-logo/ascii-logo.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/ascii-logo.svelte" - } - ], - "type": "registry:ui" - }, { "name": "dithered-404", "title": "Dithered 404", @@ -68,19 +42,6 @@ ], "type": "registry:ui" }, - { - "name": "dithered-404-svelte", - "title": "Dithered 404", - "description": "Bayer-pixel 404 section: a fireball cursor scorches the glyph, burns it into embers and smoke, then reforms. Optional soft realistic fire.", - "files": [ - { - "path": "registry/dithered-404/dithered-404.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/dithered-404.svelte" - } - ], - "type": "registry:ui" - }, { "name": "folio", "title": "Folio", @@ -94,19 +55,6 @@ ], "type": "registry:ui" }, - { - "name": "folio-svelte", - "title": "Folio", - "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.", - "files": [ - { - "path": "registry/folio/folio.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/folio.svelte" - } - ], - "type": "registry:ui" - }, { "name": "gooey-color-picker", "title": "Gooey Color Picker", @@ -123,20 +71,6 @@ ], "type": "registry:ui" }, - { - "name": "gooey-color-picker-svelte", - "title": "Gooey Color Picker", - "description": "A floating color picker with a circular hue wheel, alpha slider, hex field, and motion-powered open/close.", - "dependencies": [], - "files": [ - { - "path": "registry/gooey-color-picker/gooey-color-picker.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/gooey-color-picker.svelte" - } - ], - "type": "registry:ui" - }, { "name": "iso-lattice", "title": "Iso Lattice", @@ -150,19 +84,6 @@ ], "type": "registry:ui" }, - { - "name": "iso-lattice-svelte", - "title": "Iso Lattice", - "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", - "files": [ - { - "path": "registry/iso-lattice/iso-lattice.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/iso-lattice.svelte" - } - ], - "type": "registry:ui" - }, { "name": "live-orb", "title": "Live Orb", @@ -176,19 +97,6 @@ ], "type": "registry:ui" }, - { - "name": "live-orb-svelte", - "title": "Live Orb", - "description": "An evenly lit sphere with two capsule eyes that follow the pointer: white, black, webgl, and custom variants.", - "files": [ - { - "path": "registry/live-orb/live-orb.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/live-orb.svelte" - } - ], - "type": "registry:ui" - }, { "name": "logo-burst", "title": "Logo Burst", @@ -202,19 +110,6 @@ ], "type": "registry:ui" }, - { - "name": "logo-burst-svelte", - "title": "Logo Burst", - "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", - "files": [ - { - "path": "registry/logo-burst/logo-burst.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/logo-burst.svelte" - } - ], - "type": "registry:ui" - }, { "name": "mech-keyboard", "title": "Mech Keyboard", @@ -234,25 +129,6 @@ ], "type": "registry:ui" }, - { - "name": "mech-keyboard-svelte", - "title": "Mech Keyboard", - "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", - "dependencies": [ - "three" - ], - "devDependencies": [ - "@types/three" - ], - "files": [ - { - "path": "registry/mech-keyboard/mech-keyboard.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/mech-keyboard.svelte" - } - ], - "type": "registry:ui" - }, { "name": "phosphor-score", "title": "Phosphor Score", @@ -266,19 +142,6 @@ ], "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", @@ -292,20 +155,6 @@ ], "type": "registry:ui" }, - { - "name": "radiant-lines-svelte", - "title": "Radiant Lines", - "description": "A hyperspace starfield background: colored streaks radiate from the center, with warp speed driven by scroll velocity.", - "dependencies": [], - "files": [ - { - "path": "registry/radiant-lines/radiant-lines.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/radiant-lines.svelte" - } - ], - "type": "registry:ui" - }, { "name": "ring-tower", "title": "Ring Tower", @@ -325,25 +174,6 @@ ], "type": "registry:ui" }, - { - "name": "ring-tower-svelte", - "title": "Ring Tower", - "description": "A tower of tilted bands wrapped in your images. They crawl around, drag to spin, and neighbors counter-turn with the pointer.", - "dependencies": [ - "three" - ], - "devDependencies": [ - "@types/three" - ], - "files": [ - { - "path": "registry/ring-tower/ring-tower.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/ring-tower.svelte" - } - ], - "type": "registry:ui" - }, { "name": "shader-anime-fire", "title": "Shader Anime Fire", @@ -357,20 +187,6 @@ ], "type": "registry:ui" }, - { - "name": "shader-anime-fire-svelte", - "title": "Shader Anime Fire", - "description": "Cel-shaded WebGL flames for landing heroes: tongues lick up from the bottom in flat bands of heat, and the fire reaches for the pointer.", - "dependencies": [], - "files": [ - { - "path": "registry/shader-anime-fire/shader-anime-fire.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-anime-fire.svelte" - } - ], - "type": "registry:ui" - }, { "name": "shader-gradient", "title": "Shader Gradient", @@ -384,19 +200,6 @@ ], "type": "registry:ui" }, - { - "name": "shader-gradient-svelte", - "title": "Shader Gradient", - "description": "Quiet WebGL color wash for landing heroes, empty states, and marketing sections: theme-aware, optional pointer follow.", - "files": [ - { - "path": "registry/shader-gradient/shader-gradient.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-gradient.svelte" - } - ], - "type": "registry:ui" - }, { "name": "shader-metal", "title": "Shader Metal", @@ -410,19 +213,6 @@ ], "type": "registry:ui" }, - { - "name": "shader-metal-svelte", - "title": "Shader Metal", - "description": "Liquid chrome, gold, or copper ribbons twisting through a studio light rig: transparent WebGL, theme-aware, with a key light that follows the pointer.", - "files": [ - { - "path": "registry/shader-metal/shader-metal.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-metal.svelte" - } - ], - "type": "registry:ui" - }, { "name": "shader-sky", "title": "Shader Sky", @@ -436,19 +226,6 @@ ], "type": "registry:ui" }, - { - "name": "shader-sky-svelte", - "title": "Shader Sky", - "description": "WebGL sky for landing heroes: clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.", - "files": [ - { - "path": "registry/shader-sky/shader-sky.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-sky.svelte" - } - ], - "type": "registry:ui" - }, { "name": "stretchy-footer", "title": "Stretchy Footer", @@ -462,20 +239,6 @@ ], "type": "registry:ui" }, - { - "name": "stretchy-footer-svelte", - "title": "Stretchy Footer", - "description": "Dia-style rubber overscroll: rainbow aurora stretches up past the bottom, then snaps back.", - "dependencies": [], - "files": [ - { - "path": "registry/stretchy-footer/stretchy-footer.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/stretchy-footer.svelte" - } - ], - "type": "registry:ui" - }, { "name": "typewriter", "title": "Typewriter", @@ -488,19 +251,6 @@ } ], "type": "registry:ui" - }, - { - "name": "typewriter-svelte", - "title": "Typewriter", - "description": "A real, playable typewriter in SVG: each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", - "files": [ - { - "path": "registry/typewriter/typewriter.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/typewriter.svelte" - } - ], - "type": "registry:ui" } ] } \ No newline at end of file diff --git a/public/r/ring-tower-svelte.json b/public/r/ring-tower-svelte.json deleted file mode 100644 index 1e2b9de..0000000 --- a/public/r/ring-tower-svelte.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "ring-tower-svelte", - "title": "Ring Tower", - "description": "A tower of tilted bands wrapped in your images. They crawl around, drag to spin, and neighbors counter-turn with the pointer.", - "dependencies": [ - "three" - ], - "devDependencies": [ - "@types/three" - ], - "files": [ - { - "path": "registry/ring-tower/ring-tower.svelte", - "content": "\n\n\n\n\n", - "type": "registry:file", - "target": "src/lib/components/ui/ring-tower.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/shader-anime-fire-svelte.json b/public/r/shader-anime-fire-svelte.json deleted file mode 100644 index 3599f9e..0000000 --- a/public/r/shader-anime-fire-svelte.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$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 in flat bands of heat, 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\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-gradient-svelte.json b/public/r/shader-gradient-svelte.json deleted file mode 100644 index 1cf0a35..0000000 --- a/public/r/shader-gradient-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "shader-gradient-svelte", - "title": "Shader Gradient", - "description": "Quiet WebGL color wash for landing heroes, empty states, and marketing sections: theme-aware, optional pointer follow.", - "files": [ - { - "path": "registry/shader-gradient/shader-gradient.svelte", - "content": "\n\n\n\n\n \n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/shader-gradient.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/shader-metal-svelte.json b/public/r/shader-metal-svelte.json deleted file mode 100644 index 38d6fd9..0000000 --- a/public/r/shader-metal-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "shader-metal-svelte", - "title": "Shader Metal", - "description": "Liquid chrome, gold, or copper ribbons twisting through a studio light rig: transparent WebGL, theme-aware, with a key light that follows the pointer.", - "files": [ - { - "path": "registry/shader-metal/shader-metal.svelte", - "content": "\n\n\n\n\n \n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/shader-metal.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/shader-sky-svelte.json b/public/r/shader-sky-svelte.json deleted file mode 100644 index 5cd6903..0000000 --- a/public/r/shader-sky-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "shader-sky-svelte", - "title": "Shader Sky", - "description": "WebGL sky for landing heroes: clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.", - "files": [ - { - "path": "registry/shader-sky/shader-sky.svelte", - "content": "\n\n\n\n\n \n \n\n", - "type": "registry:file", - "target": "src/lib/components/ui/shader-sky.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/stretchy-footer-svelte.json b/public/r/stretchy-footer-svelte.json deleted file mode 100644 index a272958..0000000 --- a/public/r/stretchy-footer-svelte.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "stretchy-footer-svelte", - "title": "Stretchy Footer", - "description": "Dia-style rubber overscroll: rainbow aurora stretches up past the bottom, then snaps back.", - "dependencies": [], - "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", - "type": "registry:file", - "target": "src/lib/components/ui/stretchy-footer.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/typewriter-svelte.json b/public/r/typewriter-svelte.json deleted file mode 100644 index c5aba15..0000000 --- a/public/r/typewriter-svelte.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "typewriter-svelte", - "title": "Typewriter", - "description": "A real, playable typewriter in SVG: each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", - "files": [ - { - "path": "registry/typewriter/typewriter.svelte", - "content": "\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/registry/ascii-fluid/ascii-fluid.svelte b/registry/ascii-fluid/ascii-fluid.svelte deleted file mode 100644 index af99365..0000000 --- a/registry/ascii-fluid/ascii-fluid.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/registry/ascii-fluid/registry.json b/registry/ascii-fluid/registry.json index 92e5b7a..2f00949 100644 --- a/registry/ascii-fluid/registry.json +++ b/registry/ascii-fluid/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/ascii-fluid.tsx" } ] - }, - { - "name": "ascii-fluid-svelte", - "type": "registry:ui", - "title": "ASCII Fluid", - "description": "Mouse-trail WebGL fluid background quantized to a clean ASCII brightness ramp - zero deps, theme-aware.", - "files": [ - { - "path": "ascii-fluid.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/ascii-fluid.svelte" - } - ] } ] } diff --git a/registry/ascii-logo/ascii-logo.svelte b/registry/ascii-logo/ascii-logo.svelte deleted file mode 100644 index c7b89ae..0000000 --- a/registry/ascii-logo/ascii-logo.svelte +++ /dev/null @@ -1,145 +0,0 @@ - - - - - diff --git a/registry/ascii-logo/registry.json b/registry/ascii-logo/registry.json index 6508965..4c5a19f 100644 --- a/registry/ascii-logo/registry.json +++ b/registry/ascii-logo/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/ascii-logo.tsx" } ] - }, - { - "name": "ascii-logo-svelte", - "type": "registry:ui", - "title": "ASCII Logo", - "description": "Interactive ASCII wordmark: glyphs shove away from the cursor, then click-cycle through scatter, gravity drop, and reassemble. Zero deps.", - "files": [ - { - "path": "ascii-logo.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/ascii-logo.svelte" - } - ] } ] } diff --git a/registry/dithered-404/dithered-404.svelte b/registry/dithered-404/dithered-404.svelte deleted file mode 100644 index b60c9c0..0000000 --- a/registry/dithered-404/dithered-404.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - - -
- 404 - -
diff --git a/registry/dithered-404/registry.json b/registry/dithered-404/registry.json index b2f92dd..b40f81d 100644 --- a/registry/dithered-404/registry.json +++ b/registry/dithered-404/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/dithered-404.tsx" } ] - }, - { - "name": "dithered-404-svelte", - "type": "registry:ui", - "title": "Dithered 404", - "description": "Bayer-pixel 404 section: a fireball cursor scorches the glyph, burns it into embers and smoke, then reforms. Optional soft realistic fire.", - "files": [ - { - "path": "dithered-404.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/dithered-404.svelte" - } - ] } ] } diff --git a/registry/folio/folio.svelte b/registry/folio/folio.svelte deleted file mode 100644 index 19fb6da..0000000 --- a/registry/folio/folio.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - - - -{#if !windowScroll} -
-
- {@render children?.()} -
-
-{/if} diff --git a/registry/folio/registry.json b/registry/folio/registry.json index a9a0bdc..4b935f0 100644 --- a/registry/folio/registry.json +++ b/registry/folio/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/folio.tsx" } ] - }, - { - "name": "folio-svelte", - "type": "registry:ui", - "title": "Folio", - "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.", - "files": [ - { - "path": "folio.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/folio.svelte" - } - ] } ] } diff --git a/registry/gooey-color-picker/gooey-color-picker.svelte b/registry/gooey-color-picker/gooey-color-picker.svelte deleted file mode 100644 index 7ea5a11..0000000 --- a/registry/gooey-color-picker/gooey-color-picker.svelte +++ /dev/null @@ -1,393 +0,0 @@ - - - - -
-
-
updateWheelFromPointer(x, y))} - onkeydown={onWheelKeyDown} - > - -
- -
updateAlphaFromPointer(x))} - onkeydown={onAlphaKeyDown} - > - -
- -
- - { - hexFocused = true - }} - onblur={() => { - hexFocused = false - commitHex(hexDraft) - }} - onkeydown={onHexKeyDown} - class="min-w-0 flex-1 bg-transparent font-mono text-sm text-popover-foreground outline-none placeholder:text-muted-foreground" - /> - {#if supportsEyeDropper} - - {/if} -
-
- - -
- - diff --git a/registry/gooey-color-picker/registry.json b/registry/gooey-color-picker/registry.json index cac1717..89bbe9b 100644 --- a/registry/gooey-color-picker/registry.json +++ b/registry/gooey-color-picker/registry.json @@ -14,20 +14,6 @@ "target": "components/ui/gooey-color-picker.tsx" } ] - }, - { - "name": "gooey-color-picker-svelte", - "type": "registry:ui", - "title": "Gooey Color Picker", - "description": "A floating color picker with a circular hue wheel, alpha slider, hex field, and motion-powered open/close.", - "files": [ - { - "path": "gooey-color-picker.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/gooey-color-picker.svelte" - } - ], - "dependencies": [] } ] } diff --git a/registry/iso-lattice/iso-lattice.svelte b/registry/iso-lattice/iso-lattice.svelte deleted file mode 100644 index 3cd2262..0000000 --- a/registry/iso-lattice/iso-lattice.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/registry/iso-lattice/registry.json b/registry/iso-lattice/registry.json index a2f8971..ed059e6 100644 --- a/registry/iso-lattice/registry.json +++ b/registry/iso-lattice/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/iso-lattice.tsx" } ] - }, - { - "name": "iso-lattice-svelte", - "type": "registry:ui", - "title": "Iso Lattice", - "description": "A hex lattice of glowing tiles that builds solid isometric figures, one over the last, from the floor up. Follows light and dark.", - "files": [ - { - "path": "iso-lattice.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/iso-lattice.svelte" - } - ] } ] } diff --git a/registry/live-orb/live-orb.svelte b/registry/live-orb/live-orb.svelte deleted file mode 100644 index f3abeac..0000000 --- a/registry/live-orb/live-orb.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/registry/live-orb/registry.json b/registry/live-orb/registry.json index 00ea571..f3615f4 100644 --- a/registry/live-orb/registry.json +++ b/registry/live-orb/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/live-orb.tsx" } ] - }, - { - "name": "live-orb-svelte", - "type": "registry:ui", - "title": "Live Orb", - "description": "An evenly lit sphere with two capsule eyes that follow the pointer: white, black, webgl, and custom variants.", - "files": [ - { - "path": "live-orb.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/live-orb.svelte" - } - ] } ] } diff --git a/registry/logo-burst/logo-burst.svelte b/registry/logo-burst/logo-burst.svelte deleted file mode 100644 index 8c1741c..0000000 --- a/registry/logo-burst/logo-burst.svelte +++ /dev/null @@ -1,150 +0,0 @@ - - - - -
- - {#if children} -
-
- {@render children()} -
-
- {/if} - {#if replayOnClick} - - {/if} -
diff --git a/registry/logo-burst/registry.json b/registry/logo-burst/registry.json index d08373e..0b970bc 100644 --- a/registry/logo-burst/registry.json +++ b/registry/logo-burst/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/logo-burst.tsx" } ] - }, - { - "name": "logo-burst-svelte", - "type": "registry:ui", - "title": "Logo Burst", - "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", - "files": [ - { - "path": "logo-burst.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/logo-burst.svelte" - } - ] } ] } diff --git a/registry/mech-keyboard/mech-keyboard-vanilla.ts b/registry/mech-keyboard/mech-keyboard-vanilla.ts index b3b815c..6373bbe 100644 --- a/registry/mech-keyboard/mech-keyboard-vanilla.ts +++ b/registry/mech-keyboard/mech-keyboard-vanilla.ts @@ -1655,7 +1655,7 @@ void main() { } ` -/** Tailwind for the recorder, shared by the React and Svelte wrappers. */ +/** Tailwind for the recorder. */ export const CLASSES = { root: "relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset", stage: "absolute inset-0 opacity-0", diff --git a/registry/mech-keyboard/mech-keyboard.svelte b/registry/mech-keyboard/mech-keyboard.svelte deleted file mode 100644 index ce2bf37..0000000 --- a/registry/mech-keyboard/mech-keyboard.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - - - -
-
- {#if recorder} -
-
- {#if shortcut.keys.length === 0} - {placeholder} - {:else} - {#each shortcut.keys as key, i (shortcut.codes[i])} - {#if i > 0}+{/if} - - {key} - - {/each} - {/if} -
-
- {#if phase === "done"} - - Shortcut saved - {:else if phase === "live"} - - Recording - {:else} - Listening - {/if} -
-
- {/if} -
diff --git a/registry/mech-keyboard/registry.json b/registry/mech-keyboard/registry.json index 71e3338..014611c 100644 --- a/registry/mech-keyboard/registry.json +++ b/registry/mech-keyboard/registry.json @@ -19,25 +19,6 @@ "target": "components/ui/mech-keyboard.tsx" } ] - }, - { - "name": "mech-keyboard-svelte", - "type": "registry:ui", - "title": "Mech Keyboard", - "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", - "dependencies": [ - "three" - ], - "devDependencies": [ - "@types/three" - ], - "files": [ - { - "path": "mech-keyboard.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/mech-keyboard.svelte" - } - ] } ] } diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte deleted file mode 100644 index 472bc17..0000000 --- a/registry/phosphor-score/phosphor-score.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/registry/phosphor-score/registry.json b/registry/phosphor-score/registry.json index 9e992cc..f0cb6f2 100644 --- a/registry/phosphor-score/registry.json +++ b/registry/phosphor-score/registry.json @@ -13,19 +13,6 @@ "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" - } - ] } ] } diff --git a/registry/radiant-lines/radiant-lines.svelte b/registry/radiant-lines/radiant-lines.svelte deleted file mode 100644 index 1ca219a..0000000 --- a/registry/radiant-lines/radiant-lines.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/registry/radiant-lines/registry.json b/registry/radiant-lines/registry.json index 9863034..664fefe 100644 --- a/registry/radiant-lines/registry.json +++ b/registry/radiant-lines/registry.json @@ -13,20 +13,6 @@ "target": "components/ui/radiant-lines.tsx" } ] - }, - { - "name": "radiant-lines-svelte", - "type": "registry:ui", - "title": "Radiant Lines", - "description": "A hyperspace starfield background: colored streaks radiate from the center, with warp speed driven by scroll velocity.", - "files": [ - { - "path": "radiant-lines.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/radiant-lines.svelte" - } - ], - "dependencies": [] } ] } diff --git a/registry/ring-tower/registry.json b/registry/ring-tower/registry.json index 0d69e9f..6a25ac7 100644 --- a/registry/ring-tower/registry.json +++ b/registry/ring-tower/registry.json @@ -15,21 +15,6 @@ "target": "components/ui/ring-tower.tsx" } ] - }, - { - "name": "ring-tower-svelte", - "type": "registry:ui", - "title": "Ring Tower", - "description": "A tower of tilted bands wrapped in your images. They crawl around, drag to spin, and neighbors counter-turn with the pointer.", - "dependencies": ["three"], - "devDependencies": ["@types/three"], - "files": [ - { - "path": "ring-tower.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/ring-tower.svelte" - } - ] } ] } diff --git a/registry/ring-tower/ring-tower.svelte b/registry/ring-tower/ring-tower.svelte deleted file mode 100644 index 790f5ce..0000000 --- a/registry/ring-tower/ring-tower.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - - - - diff --git a/registry/shader-anime-fire/registry.json b/registry/shader-anime-fire/registry.json index 210ae31..ed7c773 100644 --- a/registry/shader-anime-fire/registry.json +++ b/registry/shader-anime-fire/registry.json @@ -13,20 +13,6 @@ "target": "components/ui/shader-anime-fire.tsx" } ] - }, - { - "name": "shader-anime-fire-svelte", - "type": "registry:ui", - "title": "Shader Anime Fire", - "description": "Cel-shaded WebGL flames for landing heroes: tongues lick up from the bottom in flat bands of heat, and the fire reaches for the pointer.", - "files": [ - { - "path": "shader-anime-fire.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-anime-fire.svelte" - } - ], - "dependencies": [] } ] } diff --git a/registry/shader-anime-fire/shader-anime-fire.svelte b/registry/shader-anime-fire/shader-anime-fire.svelte deleted file mode 100644 index 97f0e71..0000000 --- a/registry/shader-anime-fire/shader-anime-fire.svelte +++ /dev/null @@ -1,112 +0,0 @@ - - - - - diff --git a/registry/shader-gradient/registry.json b/registry/shader-gradient/registry.json index 97b343f..db879f7 100644 --- a/registry/shader-gradient/registry.json +++ b/registry/shader-gradient/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/shader-gradient.tsx" } ] - }, - { - "name": "shader-gradient-svelte", - "type": "registry:ui", - "title": "Shader Gradient", - "description": "Quiet WebGL color wash for landing heroes, empty states, and marketing sections: theme-aware, optional pointer follow.", - "files": [ - { - "path": "shader-gradient.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-gradient.svelte" - } - ] } ] } diff --git a/registry/shader-gradient/shader-gradient.svelte b/registry/shader-gradient/shader-gradient.svelte deleted file mode 100644 index c6205bf..0000000 --- a/registry/shader-gradient/shader-gradient.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - - - - diff --git a/registry/shader-metal/registry.json b/registry/shader-metal/registry.json index 2ccd6fd..2cc4aa9 100644 --- a/registry/shader-metal/registry.json +++ b/registry/shader-metal/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/shader-metal.tsx" } ] - }, - { - "name": "shader-metal-svelte", - "type": "registry:ui", - "title": "Shader Metal", - "description": "Liquid chrome, gold, or copper ribbons twisting through a studio light rig: transparent WebGL, theme-aware, with a key light that follows the pointer.", - "files": [ - { - "path": "shader-metal.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-metal.svelte" - } - ] } ] } diff --git a/registry/shader-metal/shader-metal.svelte b/registry/shader-metal/shader-metal.svelte deleted file mode 100644 index 2d230a9..0000000 --- a/registry/shader-metal/shader-metal.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - - - diff --git a/registry/shader-sky/registry.json b/registry/shader-sky/registry.json index dcd9b8b..a933d81 100644 --- a/registry/shader-sky/registry.json +++ b/registry/shader-sky/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/shader-sky.tsx" } ] - }, - { - "name": "shader-sky-svelte", - "type": "registry:ui", - "title": "Shader Sky", - "description": "WebGL sky for landing heroes: clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.", - "files": [ - { - "path": "shader-sky.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/shader-sky.svelte" - } - ] } ] } diff --git a/registry/shader-sky/shader-sky.svelte b/registry/shader-sky/shader-sky.svelte deleted file mode 100644 index 4e9845b..0000000 --- a/registry/shader-sky/shader-sky.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/registry/stretchy-footer/registry.json b/registry/stretchy-footer/registry.json index 70e3fa3..feded35 100644 --- a/registry/stretchy-footer/registry.json +++ b/registry/stretchy-footer/registry.json @@ -13,20 +13,6 @@ "target": "components/ui/stretchy-footer.tsx" } ] - }, - { - "name": "stretchy-footer-svelte", - "type": "registry:ui", - "title": "Stretchy Footer", - "description": "Dia-style rubber overscroll: rainbow aurora stretches up past the bottom, then snaps back.", - "files": [ - { - "path": "stretchy-footer.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/stretchy-footer.svelte" - } - ], - "dependencies": [] } ] } diff --git a/registry/stretchy-footer/stretchy-footer.svelte b/registry/stretchy-footer/stretchy-footer.svelte deleted file mode 100644 index 428baac..0000000 --- a/registry/stretchy-footer/stretchy-footer.svelte +++ /dev/null @@ -1,181 +0,0 @@ - - - - -{#snippet aurora()} - -{/snippet} - -{#if windowScroll} - {#if mounted} - -
- {@render aurora()} -
- {/if} -{:else if scrollEl} -
- {@render aurora()} -
-{:else} - -
-
-
- {@render children?.()} -
-
- {@render aurora()} -
-{/if} diff --git a/registry/typewriter/registry.json b/registry/typewriter/registry.json index 39c8742..208156c 100644 --- a/registry/typewriter/registry.json +++ b/registry/typewriter/registry.json @@ -13,19 +13,6 @@ "target": "components/ui/typewriter.tsx" } ] - }, - { - "name": "typewriter-svelte", - "type": "registry:ui", - "title": "Typewriter", - "description": "A real, playable typewriter in SVG: each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", - "files": [ - { - "path": "typewriter.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/typewriter.svelte" - } - ] } ] } diff --git a/registry/typewriter/typewriter.svelte b/registry/typewriter/typewriter.svelte deleted file mode 100644 index 70a0d4d..0000000 --- a/registry/typewriter/typewriter.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - - - -
diff --git a/scripts/registry-lib.mjs b/scripts/registry-lib.mjs index bb43b6d..3add8a1 100644 --- a/scripts/registry-lib.mjs +++ b/scripts/registry-lib.mjs @@ -1,5 +1,5 @@ import { readdirSync, readFileSync, existsSync } from "node:fs" -import { basename, dirname, join, relative } from "node:path" +import { dirname, join, relative } from "node:path" import { fileURLToPath } from "node:url" export const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..") @@ -144,30 +144,6 @@ function stripVanillaBindings(wrapper) { return out.join(newline) } -function withLangTs(attrs) { - if (/\blang\s*=/.test(attrs)) return attrs - return `${attrs} lang="ts"` -} - -function standaloneSvelte(stripped, body) { - const moduleRe = /]*\bmodule\b[^>]*)>/ - const match = moduleRe.exec(stripped) - let out - if (match) { - const open = `` - out = - stripped.slice(0, match.index) + - `${open}\n${body}\n` + - stripped.slice(match.index + match[0].length) - } else { - out = `\n\n${stripped}` - } - - return out.replace(/]*)>/g, (_full, attrs) => { - return `` - }) -} - const IMPORT_LINE_RE = /^import\b[\s\S]*?["'][^"']+["']\s*;?[ \t]*(?:\r?\n|$)/ @@ -223,26 +199,14 @@ function standaloneReact(stripped, body) { } /** - * Inline a vanilla engine into a framework wrapper so the published registry + * Inline a vanilla engine into a React wrapper so the published registry * item is a single standalone file (Canvas UI `makeStandalone`). * * @param {string} wrapper * @param {string} engine - * @param {"react" | "svelte"} kind */ -export function makeStandalone(wrapper, engine, kind) { - const body = engine.trimEnd() - const stripped = stripVanillaBindings(wrapper) - - if (kind === "svelte") { - return standaloneSvelte(stripped, body) - } - - return standaloneReact(stripped, body) -} - -function kindFromPath(filePath) { - return filePath.endsWith(".svelte") ? "svelte" : "react" +export function makeStandalone(wrapper, engine) { + return standaloneReact(stripVanillaBindings(wrapper), engine.trimEnd()) } /** @@ -269,20 +233,5 @@ export function publishedFileContent(dir, filePath) { engine += `${engine ? "\n\n" : ""}${readFileSync(enginePath, "utf8")}` } - return normalizeEol(makeStandalone(wrapper, engine, kindFromPath(filePath))) -} - -/** True when this registry item is the Svelte port of a React component. */ -export function isSvelteItem(item) { - return ( - typeof item?.name === "string" && - item.name.endsWith("-svelte") && - Array.isArray(item.files) && - item.files.some((file) => file.path?.endsWith(".svelte")) - ) -} - -/** Expected install target for a Svelte registry file. */ -export function svelteTarget(filePath) { - return `src/lib/components/ui/${basename(filePath)}` + return normalizeEol(makeStandalone(wrapper, engine)) } diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index 5f6abd6..d8d2283 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -1,8 +1,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 + Install, choose, and compose 23rd shadcn/ui registry components for React. + Use when the user wants a UI component, background, shader, WebGL wash, liquid metal or chrome, hero atmosphere, footer, 404, color picker, typewriter text, keyboard shortcut recorder, 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, @@ -14,7 +14,7 @@ metadata: # 23rd -23rd is a shadcn registry of opinionated components. Install with the shadcn CLI, then own the source. Every published item has a React build and a Svelte 5 build. This skill is the playbook. Open the linked files before inventing props. +23rd is a shadcn registry of opinionated components. Install with the shadcn CLI, then own the source. Every published item is a single React client component. This skill is the playbook. Open the linked files before inventing props. This folder is checked in twice and the copies must stay identical. `skills/23rd/` is the path the skills CLI and [skills.sh](https://skills.sh) discover. `.cursor/skills/23rd/` is what Cursor loads as `/23rd` in this repo. @@ -29,12 +29,10 @@ Do not invent props, CSS variables, or variants. If a prop is not in [apis.md](a ## Install -The consumer project must already be a shadcn project. React files import `cn` from `@/lib/utils` and land in `components/ui/`. Svelte files inline their own `cn`, use `class` (not `className`), and land in `src/lib/components/ui/`. React components are `"use client"`. +The consumer project must already be a shadcn project. React files import `cn` from `@/lib/utils` and land in `components/ui/`. React components are `"use client"`. Replace `` with the kebab-case item (`shader-gradient`, `gooey-color-picker`, …). -React: - ```bash pnpm dlx shadcn@latest add @23rd/ npx shadcn@latest add @23rd/ @@ -42,20 +40,10 @@ yarn dlx shadcn@latest add @23rd/ bunx --bun shadcn@latest add @23rd/ ``` -Svelte 5 (item suffix `-svelte`): - -```bash -pnpm dlx shadcn@latest add @23rd/-svelte -npx shadcn@latest add @23rd/-svelte -yarn dlx shadcn@latest add @23rd/-svelte -bunx --bun shadcn@latest add @23rd/-svelte -``` - GitHub source, if `@23rd` is not configured: ```bash pnpm dlx shadcn@latest add radiumcoders/23rd.dev/ -pnpm dlx shadcn@latest add radiumcoders/23rd.dev/-svelte ``` Imports after install: @@ -64,20 +52,14 @@ Imports after install: import { ShaderGradient } from "@/components/ui/shader-gradient" ``` -```svelte - -``` - npm dependencies the registry declares: -| Item | React | Svelte | -| --- | --- | --- | -| `gooey-color-picker` | `motion` | none | -| `ring-tower` | `three` | `three` | -| `mech-keyboard` | `three` | `three` | -| every other item | none | none | +| Item | Dependencies | +| --- | --- | +| `gooey-color-picker` | `motion` | +| `ring-tower` | `three` | +| `mech-keyboard` | `three` | +| every other item | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -148,7 +130,7 @@ Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `Logo `ShaderMetal` uses the same shell but is transparent: the ribbons and their shadows sit on the parent's background, so set one (`bg-background`). The ribbons cross the middle; put copy top-left. -`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. +`RadiantLines` is transparent and warps with scroll. Inside an overflow div, pass the scroller (`containerRef`) and make the canvas `sticky top-0 h-svh`. Omit the scroller to use the window. `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`). @@ -180,26 +162,26 @@ Exceptions: Categories match `content/docs/components/meta.json`. -| Name | Category | One line | Frameworks | -| --- | --- | --- | --- | -| `logo-burst` | Background | Hair-line tentacles explode from center, then breathe | React + Svelte | -| `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | React + Svelte | -| `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | React + Svelte | -| `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | React + Svelte | -| `iso-lattice` | Background | Isometric tiles build solid figures from the floor up | React + Svelte | -| `ring-tower` | Background | three.js tower of tilted image bands; drag to spin, neighbors counter-turn | React + Svelte | -| `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | React + Svelte | -| `shader-anime-fire` | Shaders | Cel-shaded flames licking up from the bottom | React + Svelte | -| `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | React + Svelte | -| `shader-metal` | Shaders | Chrome, gold, copper, or graphite ribbons; pointer key light | 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 | -| `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 real typewriter in SVG: keys press, typebars strike, the carriage steps | React + Svelte | -| `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | React + Svelte | +| Name | Category | One line | +| --- | --- | --- | +| `logo-burst` | Background | Hair-line tentacles explode from center, then breathe | +| `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | +| `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | +| `ascii-fluid` | Background | Pointer trails quantized to an ASCII brightness ramp | +| `iso-lattice` | Background | Isometric tiles build solid figures from the floor up | +| `ring-tower` | Background | three.js tower of tilted image bands; drag to spin, neighbors counter-turn | +| `shader-gradient` | Shaders | Quiet WebGL wash behind heroes and empty states | +| `shader-anime-fire` | Shaders | Cel-shaded flames licking up from the bottom | +| `shader-sky` | Shaders | Clear sky or rain; optional dotted window glass | +| `shader-metal` | Shaders | Chrome, gold, copper, or graphite ribbons; pointer key light | +| `stretchy-footer` | Footers | Dia-style rubber overscroll with an aurora floor | +| `live-orb` | Characters | Lit sphere; eyes follow the pointer | +| `ascii-logo` | Characters | ASCII wordmark: hover shove, click scatter / fall / gather | +| `dithered-404` | Pages | Bayer 404 burned by a fireball cursor, then reforms | +| `folio` | Sections | Page leans on scroll, then springs flat | +| `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | +| `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | +| `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | ## Minimal installs that must be right @@ -249,8 +231,8 @@ export function Accent() { } ``` -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). +Full prop tables are in [apis.md](apis.md). More assemblies are in [recipes.md](recipes.md). ## Adding a component -Only when the task is to add one to this repository. Follow [conventions.md](conventions.md). Short version: `registry//` with vanilla engine plus React and Svelte wrappers and a two-item `registry.json`, a doc at `content/docs/components/.mdx`, a row in `content/docs/components/meta.json`, then `pnpm registry:build` and `pnpm test`. +Only when the task is to add one to this repository. Follow [conventions.md](conventions.md). Short version: `registry//` with a vanilla engine, a React wrapper, and a `registry.json`, a doc at `content/docs/components/.mdx`, a row in `content/docs/components/meta.json`, then `pnpm registry:build` and `pnpm test`. diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 6674d77..5df5ae6 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -1,13 +1,6 @@ # Public APIs -Types below are what the React modules export. Svelte components take the same fields with these renames: - -| React | Svelte | -| --- | --- | -| `className` | `class` | -| `children?: ReactNode` | `children?: Snippet` | -| `scrollRef?: RefObject` | `scrollEl?: HTMLElement` | -| `containerRef?: RefObject` | `container?: HTMLElement` | +Types below are what the React modules export. Vanilla `onThemeChange` and `onHasGl` are not React props. The wrappers subscribe internally. `Dithered404` vanilla `onHideCursor` is also internal to the wrapper. @@ -415,7 +408,7 @@ type FolioPlayDetail = { } ``` -React exports `playFolioDemo`, `FOLIO_PLAY` (`"folio:play"`), `applyFolioFrame`. The Svelte component listens for that window event. Reduced motion: no tilt, no blur. +React exports `playFolioDemo`, `FOLIO_PLAY` (`"folio:play"`), `applyFolioFrame`. Reduced motion: no tilt, no blur. ## typewriter diff --git a/skills/23rd/components.md b/skills/23rd/components.md index eee2f84..a9b9260 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -1,6 +1,6 @@ # Component guide -React snippets are the primary examples. Svelte parity: same props, `class` instead of `className`, default import from `$lib/components/ui/.svelte`, `children` is a snippet, `scrollRef` is `scrollEl`, `containerRef` is `container`. Read [apis.md](apis.md) before adding a prop. +Read [apis.md](apis.md) before adding a prop. Shared shell for fill-the-parent pieces: @@ -46,7 +46,7 @@ Best fit: an infrastructure or developer-tool hero that wants one luminous objec Not this: hair-line explosion (`logo-burst`), scroll starfield (`radiant-lines`), images on a tower (`ring-tower`). -Deps: none (WebGL). Key props: `shapes` (`["cube", "steps", "pillars", "coil"]`), `interval` (4s), `color`, `theme` (`auto`), `size` (0.6), `spacing` (8px), `glow` (0.6), `speed` (1), `floor` (true), `interactive` (true). +Deps: none (WebGL). Key props: `shapes` (`["cube", "steps", "pillars", "coil"]`), `interval` (4s), `color`, `theme` (`auto`), `size` (0.6), `spacing` (8px), `speed` (1), `floor` (true), `interactive` (true). ```tsx "use client" @@ -99,7 +99,7 @@ Best fit: a long scrolling page that should feel like hyperspace. Not a static p Not this: `logo-burst` (one-shot explosion), `shader-gradient` (no scroll coupling). -Deps: none. Key props: `colors` (omit it and the palette follows the theme), `theme`, `starCount` (420), `displacement` (1), `containerRef` / Svelte `container`. +Deps: none. Key props: `colors` (omit it and the palette follows the theme), `theme`, `starCount` (420), `displacement` (1), `containerRef`. ```tsx "use client" @@ -282,7 +282,7 @@ Best fit: a Dia-like end of a long page. The component can be the scroller, or a Not this: tilting the page (`folio`). -Deps: none. Key props: `children`, `scrollRef` / Svelte `scrollEl`, `windowScroll` (false), `contentSelector` (`[data-stretchy-page]`), `maxStretch` (280), `colors`, `stiffness` (380), `damping` (32), `columns` (9), `blur` (16), `glow` (0), `flip` (false), `rotate` (0 or 180), `label`, `demoId`. +Deps: none. Key props: `children`, `scrollRef`, `windowScroll` (false), `contentSelector` (`[data-stretchy-page]`), `maxStretch` (280), `colors`, `stiffness` (380), `damping` (32), `columns` (9), `blur` (16), `glow` (0), `flip` (false), `rotate` (0 or 180), `label`, `demoId`. ```tsx "use client" @@ -417,7 +417,7 @@ Best fit: pick or edit one color inline. Not this: any background, shader, or theme switcher. -Deps: React needs `motion`. Svelte has none. Props: `value`, `defaultValue`, `onChange(color, css)`, `label` (`"Color picker"`), `className` / `class`. +Deps: `motion`. Props: `value`, `defaultValue`, `onChange(color, css)`, `label` (`"Color picker"`), `className`. ```tsx "use client" @@ -437,7 +437,7 @@ 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`. +Pitfalls: the panel overflows the trigger. Do not put it in `overflow-hidden`. Also exports `parseColor`. ## mech-keyboard diff --git a/skills/23rd/conventions.md b/skills/23rd/conventions.md index afb7dd0..e5a7275 100644 --- a/skills/23rd/conventions.md +++ b/skills/23rd/conventions.md @@ -8,9 +8,8 @@ Use this only when the task is to add or change a registry item in `radiumcoders registry// -vanilla.ts engine, no React .tsx "use client" wrapper - .svelte Svelte 5 wrapper -demo.tsx docs controls (optional; gooey inlines the component) - registry.json React item + -svelte item + registry.json one React item content/docs/components/.mdx ``` @@ -36,19 +35,6 @@ Match `registry/shader-gradient/registry.json`. "target": "components/ui/.tsx" } ] - }, - { - "name": "-svelte", - "type": "registry:ui", - "title": "Human Title", - "description": "Same sentence.", - "files": [ - { - "path": ".svelte", - "type": "registry:file", - "target": "src/lib/components/ui/.svelte" - } - ] } ] } @@ -56,12 +42,10 @@ Match `registry/shader-gradient/registry.json`. Rules tests enforce (`tests/registry.test.mjs`): -- Both items exist. Svelte name is `-svelte`. - `title` and `description` are non-empty strings. -- React `files[].type` is `registry:ui` and `target` is `components/ui/.tsx`. -- Svelte `files[].type` is `registry:file` and `target` is `src/lib/components/ui/.svelte` (`svelteTarget`). +- `files[].type` is `registry:ui` and `target` is `components/ui/.tsx`. - `dependencies` / `devDependencies` / `registryDependencies`, if present, are arrays of non-empty strings. -- Declare `motion` on the React item only when the wrapper imports `motion`. Svelte ports of those components currently declare no npm dependencies. +- Declare `motion` only when the wrapper imports `motion`. - Omit `dependencies` when there are none. Do not add a fake registry dependency. `scripts/build-registry.mjs` discovers `registry/*/registry.json`, rewrites the root `registry.json` `include` list, writes `.registry-standalone/` with vanilla code inlined, runs `shadcn registry validate` and `shadcn build` into `public/r/`. @@ -78,17 +62,9 @@ React: - Do not pass `onThemeChange` through. Watch theme inside the wrapper if the CSS fallback needs it. - Re-export public constants and types the docs name (`LIGHT_COLORS`, `GooeyColor`, …). -Svelte 5: - -- ` -``` - - ## Props @@ -154,7 +119,7 @@ import { Foo } from "@/components/ui/" Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components. Separators look like `"---Background---"`. -MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`, `FrameworkCode`, `FrameworkReact`, `FrameworkSvelte`. +MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`. Props tables must match the wrapper defaults. Do not copy a usage example into the Default column. @@ -174,7 +139,6 @@ pnpm typecheck ## Do not -- Publish a React-only item. Tests require a Svelte port. - Ship a component that only looks right in one theme. - Import `./-vanilla` from a file that is not the wrapper the build inlines. - Put site chrome (`components/docs-shell.tsx`, `components/ui/button.tsx`) into the registry. `components/ui/*` in this repo is the docs site’s shadcn kit, not the published set. diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index ee36002..bd81917 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -44,8 +44,6 @@ Swap the component, keep the shell: | Glowing isometric figure | `IsoLattice` | lattice in its own box under the copy | | Burst around a logo | `LogoBurst` | pass the mark as `children` | -Svelte: `import ShaderGradient from "$lib/components/ui/shader-gradient.svelte"` and `class` on the section if you style the component itself. - ## Scroll-linked starfield `radiant-lines` needs the element that actually scrolls. @@ -74,8 +72,6 @@ export function Story() { } ``` -Svelte: `bind:this={scroller}` and `container={scroller}`. - ## 404 Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. @@ -138,8 +134,6 @@ export function Document() { } ``` -Svelte uses `scrollEl` instead of `scrollRef`. - ## Page that leans ```tsx diff --git a/tests/listings.test.mjs b/tests/listings.test.mjs index 4d643aa..d70639a 100644 --- a/tests/listings.test.mjs +++ b/tests/listings.test.mjs @@ -6,16 +6,13 @@ import { join } from "node:path" import { ROOT, discoverIncludes, - isSvelteItem, readJson, } from "../scripts/registry-lib.mjs" // A component is listed by hand in several places besides registry/. These // checks keep them in step, so a new component can't ship missing from one. const names = discoverIncludes().flatMap((include) => - (readJson(join(ROOT, include)).items ?? []) - .filter((item) => !isSvelteItem(item)) - .map((item) => item.name) + (readJson(join(ROOT, include)).items ?? []).map((item) => item.name) ) const read = (path) => readFileSync(join(ROOT, path), "utf8") diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs index c94b110..8fac3b9 100644 --- a/tests/registry.test.mjs +++ b/tests/registry.test.mjs @@ -13,13 +13,11 @@ import { readRootRegistry, readJson, composeRootRegistry, - isSvelteItem, makeStandalone, detectVanillaImports, manifestDir, normalizeEol, publishedFileContent, - svelteTarget, } from "../scripts/registry-lib.mjs" const rootRegistry = readRootRegistry() @@ -74,20 +72,10 @@ test("every included manifest exists and is valid JSON with items", () => { } }) -test("every React item has a matching Svelte port", () => { - const names = new Set(items.map(({ item }) => item.name)) - const reactNames = items - .map(({ item }) => item.name) - .filter((name) => !name.endsWith("-svelte")) - const missing = reactNames.filter((name) => !names.has(`${name}-svelte`)) - assert.deepEqual(missing, [], `missing svelte ports: ${missing.join(", ")}`) -}) - test("makeStandalone inlines a vanilla engine into a React wrapper", () => { const out = makeStandalone( `"use client"\n\nimport { createFoo } from "./foo-vanilla"\n\nexport { LIGHT } from "./foo-vanilla"\n\nexport function Foo() {\n createFoo()\n}\n`, - `export const LIGHT = "#fff"\nexport function createFoo() {}\n`, - "react" + `export const LIGHT = "#fff"\nexport function createFoo() {}\n` ) assert.match(out, /export function createFoo/) assert.match(out, /export const LIGHT/) @@ -102,8 +90,7 @@ test("makeStandalone inlines a vanilla engine into a React wrapper", () => { test("makeStandalone react emits wrapper imports after use client", () => { const out = makeStandalone( `"use client"\n\nimport { createFoo } from "./foo-vanilla"\nimport { useEffect } from "react"\n\nexport function Foo() {\n createFoo()\n}\n`, - `export function createFoo() {}\n`, - "react" + `export function createFoo() {}\n` ) assert.match(out, /^"use client"\s*\n\s*import \{ useEffect \} from "react"/) const client = out.indexOf("use client") @@ -112,23 +99,6 @@ test("makeStandalone react emits wrapper imports after use client", () => { assert.ok(client >= 0 && imp > client && engine > imp) }) -test("makeStandalone injects the engine into a Svelte module script", () => { - const out = makeStandalone( - `\n\n\n`, - `export function createFoo() {}\n`, - "svelte" - ) - assert.match(out, /\n\n\n`, - `export function createFoo() {}\n`, - "svelte" - ) - assert.match(out, / - -
- -
Your content
-
-``` - - ## Browse @@ -60,5 +44,5 @@ export function Hero() { | Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | | Pages | [Dithered 404](/docs/components/dithered-404) | | Sections | [Folio](/docs/components/folio) | -| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | +| Components | [Dither Scan](/docs/components/dither-scan), [Gooey Color Picker](/docs/components/gooey-color-picker) | | Experiments | [Phosphor Score](/docs/components/phosphor-score), [Typewriter](/docs/components/typewriter) | diff --git a/public/og/docs/components/dither-scan/image.png b/public/og/docs/components/dither-scan/image.png new file mode 100644 index 0000000000000000000000000000000000000000..4f323f26d61c8a906e52304fc4175529fe09225b GIT binary patch literal 42856 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 void\n /** Fires when the first sweep starts, including each loop. */\n onStart?: () => void\n /** Fires after each sweep, 1-based. */\n onPass?: (pass: number, passes: number) => void\n /** Fires when the image is clean. */\n onComplete?: () => void\n /** Fires when a scan starts or settles. */\n onScanningChange?: (scanning: boolean) => void\n}\n\nexport type DitherScanInstance = {\n setOptions: (options: Partial) => void\n /** Re-dither and scan from the first pass. */\n replay: () => void\n destroy: () => void\n}\n\n/** Ink and paper for the 1-bit print on a light page */\nexport const LIGHT_COLORS = [\"#18181B\", \"#F4F4F5\"] as const\n/** Ink and paper for the 1-bit print on a dark page */\nexport const DARK_COLORS = [\"#09090B\", \"#D4D4D8\"] as const\n\nconst MAX_PASSES = 12\nconst MAX_LEVELS = 12\nconst LINE_WIDTH = 1.5\nconst TRAIL = 56\nconst BLUR_BEHIND = 128\nconst BLUR_AHEAD = 36\n/** Gradient stops sampled from smoothstep; a linear ramp shows bands. */\nconst BLUR_STOPS = 12\nconst RESET_MS = 520\nconst REBUILD_DELAY_MS = 120\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\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: DitherScanTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\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 [0, 0, 0]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n}\n\nfunction clampPasses(passes: number | undefined) {\n const n = Math.round(passes ?? 4)\n if (!Number.isFinite(n)) return 4\n return Math.min(MAX_PASSES, Math.max(1, n))\n}\n\nfunction smoothstep(t: number) {\n return t * t * (3 - 2 * t)\n}\n\nfunction easeInOutSine(t: number) {\n return -(Math.cos(Math.PI * t) - 1) / 2\n}\n\n/**\n * How a pass level looks. Level 0 is the 1-bit print; level `passes` is the\n * source. Cells shrink geometrically, tones and color return linearly.\n */\nexport function levelSpec(level: number, passes: number, pixelSize: number) {\n const t = level / passes\n return {\n cell: Math.max(1, Math.round(Math.max(1, pixelSize) ** (1 - t))),\n tones: 2 + Math.round(t * (MAX_LEVELS - 2)),\n color: t,\n }\n}\n\ntype Fit = { x: number; y: number; w: number; h: number }\n\nfunction fitRect(\n iw: number,\n ih: number,\n w: number,\n h: number,\n fit: DitherScanFit\n): Fit {\n const scale =\n fit === \"contain\" ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih)\n const dw = iw * scale\n const dh = ih * scale\n return { x: (w - dw) / 2, y: (h - dh) / 2, w: dw, h: dh }\n}\n\n/**\n * Ordered-dither the image at one pass level into a small canvas, one pixel\n * per cell. Drawn back up with smoothing off, each pixel is a crisp cell.\n * Returns null if the image taints the canvas (no CORS).\n */\nfunction renderLevel(\n image: HTMLImageElement,\n w: number,\n h: number,\n fit: DitherScanFit,\n spec: ReturnType,\n shadow: [number, number, number],\n highlight: [number, number, number]\n): HTMLCanvasElement | null {\n const sw = Math.max(1, Math.ceil(w / spec.cell))\n const sh = Math.max(1, Math.ceil(h / spec.cell))\n const out = document.createElement(\"canvas\")\n out.width = sw\n out.height = sh\n const ctx = out.getContext(\"2d\", { willReadFrequently: true })\n if (!ctx) return null\n\n const r = fitRect(image.naturalWidth, image.naturalHeight, w, h, fit)\n ctx.imageSmoothingEnabled = true\n ctx.imageSmoothingQuality = \"high\"\n ctx.drawImage(\n image,\n r.x / spec.cell,\n r.y / spec.cell,\n r.w / spec.cell,\n r.h / spec.cell\n )\n\n let data: ImageData\n try {\n data = ctx.getImageData(0, 0, sw, sh)\n } catch {\n return null\n }\n\n const px = data.data\n const steps = spec.tones - 1\n const mix = spec.color\n const keep = 1 - mix\n const [sr, sg, sb] = shadow\n const dr = highlight[0] - sr\n const dg = highlight[1] - sg\n const db = highlight[2] - sb\n\n for (let y = 0; y < sh; y++) {\n const row = BAYER8[y & 7]!\n for (let x = 0; x < sw; x++) {\n const i = (y * sw + x) * 4\n if (px[i + 3]! === 0) continue\n const bias = (row[x & 7]! + 0.5) / 64 - 0.5\n const r8 = px[i]!\n const g8 = px[i + 1]!\n const b8 = px[i + 2]!\n\n const lum = (0.2126 * r8 + 0.7152 * g8 + 0.0722 * b8) / 255\n let tone = Math.round(lum * steps + bias) / steps\n tone = tone < 0 ? 0 : tone > 1 ? 1 : tone\n\n let qr = r8\n let qg = g8\n let qb = b8\n if (mix > 0) {\n qr = Math.round((r8 / 255) * steps + bias) / steps\n qg = Math.round((g8 / 255) * steps + bias) / steps\n qb = Math.round((b8 / 255) * steps + bias) / steps\n qr = (qr < 0 ? 0 : qr > 1 ? 1 : qr) * 255\n qg = (qg < 0 ? 0 : qg > 1 ? 1 : qg) * 255\n qb = (qb < 0 ? 0 : qb > 1 ? 1 : qb) * 255\n }\n\n px[i] = (sr + dr * tone) * keep + qr * mix\n px[i + 1] = (sg + dg * tone) * keep + qg * mix\n px[i + 2] = (sb + db * tone) * keep + qb * mix\n px[i + 3] = 255\n }\n }\n\n ctx.putImageData(data, 0, 0)\n return out\n}\n\nconst IDLE = 0\nconst SWEEP = 1\nconst HOLD = 2\nconst DONE = 3\nconst RESET = 4\n\nexport function createDitherScan(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: DitherScanOptions\n): DitherScanInstance | null {\n let options: DitherScanOptions = {\n passes: 4,\n duration: 1400,\n pause: 280,\n angle: 45,\n alternate: true,\n pixelSize: 6,\n blur: 6,\n fit: \"cover\",\n autoPlay: true,\n loop: false,\n loopDelay: 2400,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let running = true\n let raf = 0\n let rebuildTimer = 0\n let loopTimer = 0\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let visible = false\n let image: HTMLImageElement | null = null\n let loadedSrc = \"\"\n /** `levels[k]` is pass level k; the last is null and draws the source. */\n let levels: (HTMLCanvasElement | null)[] = []\n /** CSS px per level pixel, matching `levels`. */\n let cells: number[] = []\n let levelsW = 0\n let levelsH = 0\n /** Blurred copies of levels, made the first time a sweep needs one. */\n const blurred = new Map()\n let scratch: HTMLCanvasElement | null = null\n let warmTimer = 0\n let tainted = false\n\n let state = IDLE\n /** Sweeps finished; the frame shows `levels[pass]`. */\n let pass = 0\n let phaseStart = 0\n let wantsPlay = false\n let scanning = false\n\n const passes = () => clampPasses(options.passes)\n\n const setScanning = (next: boolean) => {\n if (scanning === next) return\n scanning = next\n options.onScanningChange?.(next)\n }\n\n const palette = () => {\n const pair = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n return [hexRgb(pair[0]), hexRgb(pair[1])] as const\n }\n\n const rebuild = () => {\n window.clearTimeout(rebuildTimer)\n rebuildTimer = 0\n if (!image || cssW <= 0 || cssH <= 0) return\n const n = passes()\n const [shadow, highlight] = palette()\n const fit = options.fit ?? \"cover\"\n const pixelSize = options.pixelSize ?? 6\n const next: (HTMLCanvasElement | null)[] = []\n const nextCells: number[] = []\n tainted = false\n for (let k = 0; k < n; k++) {\n const spec = levelSpec(k, n, pixelSize)\n const level = renderLevel(\n image,\n cssW,\n cssH,\n fit,\n spec,\n shadow,\n highlight\n )\n if (!level) {\n tainted = true\n break\n }\n next.push(level)\n nextCells.push(spec.cell)\n }\n // A cross-origin image without CORS can't be read: show it as is.\n levels = tainted ? [null] : [...next, null]\n cells = tainted ? [1] : [...nextCells, 1]\n levelsW = cssW\n levelsH = cssH\n blurred.clear()\n warmBlur()\n if (pass > levels.length - 1) pass = levels.length - 1\n }\n\n const drawLevel = (k: number, target: CanvasRenderingContext2D = ctx) => {\n const level = levels[k]\n if (level === undefined) return\n if (level === null) {\n if (!image) return\n const r = fitRect(\n image.naturalWidth,\n image.naturalHeight,\n cssW,\n cssH,\n options.fit ?? \"cover\"\n )\n target.imageSmoothingEnabled = true\n target.imageSmoothingQuality = \"high\"\n target.drawImage(image, r.x, r.y, r.w, r.h)\n return\n }\n // Stretch to the current size; a resize rebuilds after it settles.\n const cell = cells[k] ?? 1\n target.imageSmoothingEnabled = false\n target.drawImage(\n level,\n 0,\n 0,\n level.width * cell * (cssW / levelsW),\n level.height * cell * (cssH / levelsH)\n )\n }\n\n /**\n * Level `k` at CSS size, blurred. The sharp copy goes under it so the\n * frame's edges don't blur into transparency. Null where canvas filters\n * are unsupported.\n */\n const blurredLevel = (k: number) => {\n if (blurred.has(k)) return blurred.get(k)!\n const out = document.createElement(\"canvas\")\n out.width = Math.max(1, Math.round(cssW))\n out.height = Math.max(1, Math.round(cssH))\n const bctx = out.getContext(\"2d\")\n let result: HTMLCanvasElement | null = null\n if (bctx && typeof bctx.filter === \"string\") {\n drawLevel(k, bctx)\n bctx.filter = `blur(${options.blur ?? 6}px)`\n drawLevel(k, bctx)\n bctx.filter = \"none\"\n result = out\n }\n blurred.set(k, result)\n return result\n }\n\n /**\n * Blur every level ahead of time, one per task, so a sweep never stalls a\n * frame making the copy it needs.\n */\n const warmBlur = () => {\n window.clearTimeout(warmTimer)\n if ((options.blur ?? 6) <= 0 || reduce) return\n const step = (k: number) => {\n if (!running || k >= levels.length) return\n blurredLevel(k)\n warmTimer = window.setTimeout(() => step(k + 1), 0)\n }\n warmTimer = window.setTimeout(() => step(0), 0)\n }\n\n /**\n * A feathered band of blur riding the line: the incoming level softens\n * behind it and sharpens as the line moves on, the outgoing one just ahead.\n */\n const drawScanBlur = (a: number, u: number, e: number, f: number) => {\n if ((options.blur ?? 6) <= 0) return\n const ahead = blurredLevel(pass)\n const behind = blurredLevel(pass + 1)\n if (!ahead || !behind) return\n\n if (!scratch) scratch = document.createElement(\"canvas\")\n const w = Math.max(1, Math.round(cssW))\n const h = Math.max(1, Math.round(cssH))\n if (scratch.width !== w || scratch.height !== h) {\n scratch.width = w\n scratch.height = h\n }\n const sctx = scratch.getContext(\"2d\")\n if (!sctx) return\n\n sctx.setTransform(1, 0, 0, 1, 0, 0)\n sctx.globalCompositeOperation = \"source-over\"\n sctx.clearRect(0, 0, w, h)\n sctx.drawImage(ahead, 0, 0)\n\n sctx.save()\n sctx.translate(cssW / 2, cssH / 2)\n sctx.rotate(a)\n sctx.beginPath()\n sctx.rect(-e - 2, -f, u + e + 2, f * 2)\n sctx.clip()\n sctx.setTransform(1, 0, 0, 1, 0, 0)\n sctx.drawImage(behind, 0, 0)\n sctx.restore()\n\n // Keep only the band. The fill covers the whole frame, so the mask\n // doesn't depend on how a browser bounds `destination-in`.\n sctx.save()\n sctx.translate(cssW / 2, cssH / 2)\n sctx.rotate(a)\n const band = BLUR_BEHIND + BLUR_AHEAD\n const peak = BLUR_BEHIND / band\n const mask = sctx.createLinearGradient(u - BLUR_BEHIND, 0, u + BLUR_AHEAD, 0)\n for (let i = 0; i <= BLUR_STOPS; i++) {\n const x = i / BLUR_STOPS\n const rise = x * peak\n const fall = peak + x * (1 - peak)\n mask.addColorStop(rise, `rgba(0,0,0,${smoothstep(x)})`)\n mask.addColorStop(fall, `rgba(0,0,0,${smoothstep(1 - x)})`)\n }\n sctx.globalCompositeOperation = \"destination-in\"\n sctx.fillStyle = mask\n sctx.fillRect(-e - band - 4, -f, (e + band + 4) * 2, f * 2)\n sctx.restore()\n\n ctx.drawImage(scratch, 0, 0, cssW, cssH)\n }\n\n /** Draw the line and its trail, rotated to the travel direction. */\n const drawLine = (u: number, f: number) => {\n const color = options.lineColor\n const trail = ctx.createLinearGradient(u - TRAIL, 0, u, 0)\n if (color) {\n const [r, g, b] = hexRgb(color)\n trail.addColorStop(0, `rgba(${r},${g},${b},0)`)\n trail.addColorStop(1, `rgba(${r},${g},${b},0.28)`)\n } else {\n trail.addColorStop(0, \"rgba(255,255,255,0)\")\n trail.addColorStop(1, \"rgba(255,255,255,0.22)\")\n }\n ctx.globalCompositeOperation = color ? \"source-over\" : \"soft-light\"\n ctx.fillStyle = trail\n ctx.fillRect(u - TRAIL, -f, TRAIL, f * 2)\n\n ctx.globalCompositeOperation = color ? \"source-over\" : \"difference\"\n ctx.fillStyle = color ?? \"#FFFFFF\"\n ctx.fillRect(u - LINE_WIDTH / 2, -f, LINE_WIDTH, f * 2)\n ctx.globalCompositeOperation = \"source-over\"\n }\n\n const draw = (now: number) => {\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.clearRect(0, 0, cssW, cssH)\n if (!image || levels.length === 0) return\n\n if (state === RESET) {\n const t = Math.min(1, (now - phaseStart) / RESET_MS)\n drawLevel(levels.length - 1)\n ctx.globalAlpha = easeInOutSine(t)\n drawLevel(0)\n ctx.globalAlpha = 1\n return\n }\n\n drawLevel(pass)\n if (state !== SWEEP) return\n\n const t = easeInOutSine(\n Math.min(1, (now - phaseStart) / Math.max(1, options.duration ?? 1400))\n )\n const flip = options.alternate !== false && pass % 2 === 1\n const a = (((options.angle ?? 45) + (flip ? 180 : 0)) * Math.PI) / 180\n const cos = Math.abs(Math.cos(a))\n const sin = Math.abs(Math.sin(a))\n // Half-extents of the frame along the travel axis (e) and across it (f).\n const e = (cos * cssW + sin * cssH) / 2\n const f = (sin * cssW + cos * cssH) / 2 + 2\n // With blur on, travel until the band has fully entered and left the\n // frame, so it never pops in or out at a pass boundary.\n const soft = (options.blur ?? 6) > 0\n const from = -e - LINE_WIDTH - (soft ? BLUR_AHEAD : 0)\n const to = e + LINE_WIDTH + (soft ? BLUR_BEHIND : 0)\n const u = from + (to - from) * t\n\n ctx.save()\n ctx.translate(cssW / 2, cssH / 2)\n ctx.rotate(a)\n ctx.beginPath()\n ctx.rect(-e - 2, -f, u + e + 2, f * 2)\n ctx.clip()\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n drawLevel(pass + 1)\n ctx.restore()\n\n drawScanBlur(a, u, e, f)\n\n ctx.save()\n ctx.translate(cssW / 2, cssH / 2)\n ctx.rotate(a)\n drawLine(u, f)\n ctx.restore()\n }\n\n const scheduleLoop = () => {\n window.clearTimeout(loopTimer)\n if (!options.loop || reduce || levels.length < 2) return\n loopTimer = window.setTimeout(() => {\n if (!running || state !== DONE) return\n state = RESET\n phaseStart = performance.now()\n setScanning(true)\n startLoop()\n }, Math.max(0, options.loopDelay ?? 2400))\n }\n\n const finish = () => {\n state = DONE\n pass = levels.length - 1\n setScanning(false)\n options.onComplete?.()\n scheduleLoop()\n }\n\n const tick = (now: number) => {\n raf = 0\n if (!running) return\n const last = levels.length - 1\n\n if (state === SWEEP) {\n if (now - phaseStart >= Math.max(1, options.duration ?? 1400)) {\n pass += 1\n options.onPass?.(pass, last)\n if (pass >= last) {\n finish()\n } else {\n state = HOLD\n phaseStart = now\n }\n }\n } else if (state === HOLD) {\n if (now - phaseStart >= Math.max(0, options.pause ?? 280)) {\n state = SWEEP\n phaseStart = now\n }\n } else if (state === RESET) {\n if (now - phaseStart >= RESET_MS) {\n state = IDLE\n pass = 0\n begin(now)\n }\n }\n\n draw(now)\n if (state === SWEEP || state === HOLD || state === RESET) {\n raf = requestAnimationFrame(tick)\n }\n }\n\n const startLoop = () => {\n if (!raf && running) raf = requestAnimationFrame(tick)\n }\n\n const redraw = () => {\n if (raf) return\n draw(performance.now())\n }\n\n /** Start sweeping from the current level, once there is something to show. */\n const begin = (now = performance.now()) => {\n wantsPlay = false\n const last = levels.length - 1\n if (last <= 0 || reduce) {\n pass = Math.max(0, last)\n state = DONE\n redraw()\n if (last >= 0) options.onComplete?.()\n return\n }\n if (pass === 0) options.onStart?.()\n state = SWEEP\n phaseStart = now\n setScanning(true)\n startLoop()\n }\n\n const requestPlay = () => {\n if (!image || levels.length === 0 || !visible) {\n wantsPlay = true\n return\n }\n begin()\n }\n\n const restart = (play: boolean) => {\n window.clearTimeout(loopTimer)\n cancelAnimationFrame(raf)\n raf = 0\n state = IDLE\n pass = 0\n setScanning(false)\n redraw()\n if (play) requestPlay()\n }\n\n /** Returns whether the CSS size changed. */\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return false\n const changed = w !== cssW || h !== cssH\n cssW = w\n cssH = h\n const bw = Math.max(1, Math.floor(w * dpr))\n const bh = Math.max(1, Math.floor(h * dpr))\n if (bw !== canvas.width || bh !== canvas.height) {\n canvas.width = bw\n canvas.height = bh\n }\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n return changed\n }\n\n const load = (src: string) => {\n loadedSrc = src\n levels = []\n image = null\n restart(false)\n if (!src) return\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.decoding = \"async\"\n img.onload = () => {\n if (!running || loadedSrc !== src) return\n image = img\n options.onLoad?.(img.naturalWidth, img.naturalHeight)\n resize()\n rebuild()\n redraw()\n if (options.autoPlay !== false || wantsPlay) requestPlay()\n }\n img.src = src\n }\n\n resize()\n\n const ro = new ResizeObserver(() => {\n if (!resize() || !image) return\n if (levels.length === 0) rebuild()\n redraw()\n window.clearTimeout(rebuildTimer)\n rebuildTimer = window.setTimeout(() => {\n rebuild()\n redraw()\n if (wantsPlay) requestPlay()\n }, REBUILD_DELAY_MS)\n })\n ro.observe(wrap)\n\n const io = new IntersectionObserver(\n (entries) => {\n visible = entries.some((entry) => entry.isIntersecting)\n if (visible && wantsPlay) requestPlay()\n },\n { threshold: 0.35 }\n )\n io.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 const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n if (!options.colors) {\n rebuild()\n redraw()\n }\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 wantsPlay = options.autoPlay !== false\n load(options.src)\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (options.src !== loadedSrc) {\n wantsPlay = options.autoPlay !== false\n load(options.src)\n return\n }\n const passesChanged = clampPasses(prev.passes) !== passes()\n const looks =\n passesChanged ||\n prev.pixelSize !== options.pixelSize ||\n prev.fit !== options.fit ||\n prev.colors?.[0] !== options.colors?.[0] ||\n prev.colors?.[1] !== options.colors?.[1]\n if (prev.theme !== options.theme) {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n if (looks || prev.theme !== options.theme) rebuild()\n else if (prev.blur !== options.blur) {\n blurred.clear()\n warmBlur()\n }\n if (passesChanged) {\n restart(options.autoPlay !== false)\n return\n }\n if (prev.loop !== options.loop && state === DONE) scheduleLoop()\n if (prev.autoPlay === false && options.autoPlay !== false && state === IDLE) {\n requestPlay()\n }\n redraw()\n },\n replay() {\n restart(true)\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n window.clearTimeout(rebuildTimer)\n window.clearTimeout(loopTimer)\n window.clearTimeout(warmTimer)\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n options.onScanningChange?.(false)\n },\n }\n}\n\nexport type DitherScanProps = Omit<\n DitherScanOptions,\n \"onLoad\" | \"onScanningChange\"\n> & {\n /** Accessible description of the image. */\n alt?: string\n /** Increment to re-dither and scan again. */\n replayKey?: number\n className?: string\n}\n\n/**\n * An image that starts as a 1-bit ordered dither. A diagonal scan line\n * sweeps it `passes` times; each sweep sharpens the cells and returns tone\n * and color, and the last one leaves the source image.\n */\nexport function DitherScan({\n src,\n alt = \"\",\n passes = 4,\n duration = 1400,\n pause = 280,\n angle = 45,\n alternate = true,\n pixelSize = 6,\n blur = 6,\n colors,\n lineColor,\n fit = \"cover\",\n autoPlay = true,\n loop = false,\n loopDelay = 2400,\n theme = \"auto\",\n replayKey = 0,\n onStart,\n onPass,\n onComplete,\n className,\n}: DitherScanProps) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n const callbacksRef = useRef({ onStart, onPass, onComplete })\n const [ratio, setRatio] = useState(null)\n const [scanning, setScanning] = useState(false)\n\n useEffect(() => {\n callbacksRef.current = { onStart, onPass, onComplete }\n }, [onStart, onPass, onComplete])\n\n const shadow = colors?.[0]\n const highlight = colors?.[1]\n\n const options = {\n src,\n passes,\n duration,\n pause,\n angle,\n alternate,\n pixelSize,\n blur,\n colors,\n lineColor,\n fit,\n autoPlay,\n loop,\n loopDelay,\n theme,\n }\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createDitherScan(wrap, canvas, {\n ...options,\n onLoad: (w, h) => setRatio(`${w} / ${h}`),\n onScanningChange: setScanning,\n onStart: () => callbacksRef.current.onStart?.(),\n onPass: (pass, total) => callbacksRef.current.onPass?.(pass, total),\n onComplete: () => callbacksRef.current.onComplete?.(),\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(options)\n // `options` is rebuilt every render; its fields are the real deps.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n src,\n passes,\n duration,\n pause,\n angle,\n alternate,\n pixelSize,\n blur,\n shadow,\n highlight,\n lineColor,\n fit,\n autoPlay,\n loop,\n loopDelay,\n theme,\n ])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n return (\n \n \n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/dither-scan.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/registry.json b/public/r/registry.json index deb37c8..6475dce 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -29,6 +29,19 @@ ], "type": "registry:ui" }, + { + "name": "dither-scan", + "title": "Dither Scan", + "description": "An image that starts as a 1-bit ordered dither. A diagonal scan line sweeps it a set number of times, and each pass sharpens the cells and brings back tone and color until the source is clean.", + "files": [ + { + "path": "registry/dither-scan/dither-scan.tsx", + "type": "registry:ui", + "target": "components/ui/dither-scan.tsx" + } + ], + "type": "registry:ui" + }, { "name": "dithered-404", "title": "Dithered 404", diff --git a/registry.json b/registry.json index 842277c..523afb8 100644 --- a/registry.json +++ b/registry.json @@ -5,6 +5,7 @@ "include": [ "registry/ascii-fluid/registry.json", "registry/ascii-logo/registry.json", + "registry/dither-scan/registry.json", "registry/dithered-404/registry.json", "registry/folio/registry.json", "registry/gooey-color-picker/registry.json", diff --git a/registry/dither-scan/dither-scan-demo.tsx b/registry/dither-scan/dither-scan-demo.tsx new file mode 100644 index 0000000..7ce6403 --- /dev/null +++ b/registry/dither-scan/dither-scan-demo.tsx @@ -0,0 +1,208 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" + +import { + ComponentControls, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { Button } from "@/components/ui/button" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { cn } from "@/lib/utils" +import { DitherScan } from "@/registry/dither-scan/dither-scan" + +const photo = (id: string) => + `https://images.unsplash.com/photo-${id}?w=1400&q=80&auto=format` + +const IMAGES = [ + { + src: photo("1470071459604-3b5ec3a7fe05"), + alt: "Mist rolling over green highland cliffs at sunrise", + }, + { + src: photo("1433086966358-54859d0ed716"), + alt: "A waterfall dropping under a stone footbridge in a mossy gorge", + }, + { + src: photo("1506905925346-21bda4d32df4"), + alt: "Snowy mountain ridges above a sea of clouds", + }, +] + +const thumb = (src: string) => src.replace("w=1400&q=80", "w=160&q=60") + +export function DitherScanDemo() { + const defaults = useMemo( + () => ({ + image: 0, + passes: 4, + duration: 1400, + pixelSize: 6, + blur: 6, + angle: 45, + alternate: true, + loop: true, + }), + [] + ) + + const { props, updateProp, resetProps, hasChanges } = + usePreviewProps(defaults) + const [replayKey, setReplayKey] = useState(0) + const [cleared, setCleared] = useState(0) + + const onStart = useCallback(() => setCleared(0), []) + const onPass = useCallback((pass: number) => setCleared(pass), []) + + const image = IMAGES[props.image] ?? IMAGES[0]! + const status = + cleared >= props.passes + ? "Clean" + : `${cleared} of ${props.passes} passes` + + return ( + <> + +
+ +
+
+ + +
+ + Image + +
+ {IMAGES.map((option, i) => ( + + ))} +
+
+
+ + {status} + + +
+ updateProp("passes", v)} + /> + updateProp("duration", v)} + /> + updateProp("pixelSize", v)} + /> + updateProp("blur", v)} + /> + updateProp("angle", v)} + /> + updateProp("alternate", v)} + /> + updateProp("loop", v)} + /> +
+ + ) +} diff --git a/registry/dither-scan/dither-scan-vanilla.ts b/registry/dither-scan/dither-scan-vanilla.ts new file mode 100644 index 0000000..e49d7de --- /dev/null +++ b/registry/dither-scan/dither-scan-vanilla.ts @@ -0,0 +1,803 @@ +export type DitherScanTheme = "light" | "dark" | "auto" + +export type DitherScanFit = "cover" | "contain" + +export type DitherScanOptions = { + /** Image URL. Remote images need CORS (`Access-Control-Allow-Origin`). */ + src: string + /** Sweeps it takes to clear the dither. Default `4`, clamped to 1–12 */ + passes?: number + /** Milliseconds per sweep. Default `1400` */ + duration?: number + /** Milliseconds between sweeps. Default `280` */ + pause?: number + /** Direction the line travels, in degrees (0 = left to right). Default `45` */ + angle?: number + /** Every other sweep runs back the way it came. Default `true` */ + alternate?: boolean + /** Dither cell size of the first pass, in CSS px. Default `6` */ + pixelSize?: number + /** `[shadow, highlight]` for the 1-bit print. Default follows theme. */ + colors?: readonly [string, string] + /** Blur in CSS px that follows the line while it settles. `0` turns it off. Default `6` */ + blur?: number + /** Scan line color. Default inverts whatever is under it. */ + lineColor?: string + /** How the image fills the frame. Default `cover` */ + fit?: DitherScanFit + /** Start once the frame scrolls into view. Default `true` */ + autoPlay?: boolean + /** Re-dither and scan again after a hold. Default `false` */ + loop?: boolean + /** Hold on the clean image before a loop, in ms. Default `2400` */ + loopDelay?: number + /** + * Palette mode. Default `auto` follows shadcn / next-themes + * (`html.dark` class). + */ + theme?: DitherScanTheme + /** Fires with the image's natural size once it loads. */ + onLoad?: (width: number, height: number) => void + /** Fires when the first sweep starts, including each loop. */ + onStart?: () => void + /** Fires after each sweep, 1-based. */ + onPass?: (pass: number, passes: number) => void + /** Fires when the image is clean. */ + onComplete?: () => void + /** Fires when a scan starts or settles. */ + onScanningChange?: (scanning: boolean) => void +} + +export type DitherScanInstance = { + setOptions: (options: Partial) => void + /** Re-dither and scan from the first pass. */ + replay: () => void + destroy: () => void +} + +/** Ink and paper for the 1-bit print on a light page */ +export const LIGHT_COLORS = ["#18181B", "#F4F4F5"] as const +/** Ink and paper for the 1-bit print on a dark page */ +export const DARK_COLORS = ["#09090B", "#D4D4D8"] as const + +const MAX_PASSES = 12 +const MAX_LEVELS = 12 +const LINE_WIDTH = 1.5 +const TRAIL = 56 +const BLUR_BEHIND = 128 +const BLUR_AHEAD = 36 +/** Gradient stops sampled from smoothstep; a linear ramp shows bands. */ +const BLUR_STOPS = 12 +const RESET_MS = 520 +const REBUILD_DELAY_MS = 120 + +const BAYER8 = [ + [0, 32, 8, 40, 2, 34, 10, 42], + [48, 16, 56, 24, 50, 18, 58, 26], + [12, 44, 4, 36, 14, 46, 6, 38], + [60, 28, 52, 20, 62, 30, 54, 22], + [3, 35, 11, 43, 1, 33, 9, 41], + [51, 19, 59, 27, 49, 17, 57, 25], + [15, 47, 7, 39, 13, 45, 5, 37], + [63, 31, 55, 23, 61, 29, 53, 21], +] as const + +/** 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: DitherScanTheme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +function hexRgb(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, 0, 0] + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +function clampPasses(passes: number | undefined) { + const n = Math.round(passes ?? 4) + if (!Number.isFinite(n)) return 4 + return Math.min(MAX_PASSES, Math.max(1, n)) +} + +function smoothstep(t: number) { + return t * t * (3 - 2 * t) +} + +function easeInOutSine(t: number) { + return -(Math.cos(Math.PI * t) - 1) / 2 +} + +/** + * How a pass level looks. Level 0 is the 1-bit print; level `passes` is the + * source. Cells shrink geometrically, tones and color return linearly. + */ +export function levelSpec(level: number, passes: number, pixelSize: number) { + const t = level / passes + return { + cell: Math.max(1, Math.round(Math.max(1, pixelSize) ** (1 - t))), + tones: 2 + Math.round(t * (MAX_LEVELS - 2)), + color: t, + } +} + +type Fit = { x: number; y: number; w: number; h: number } + +function fitRect( + iw: number, + ih: number, + w: number, + h: number, + fit: DitherScanFit +): Fit { + const scale = + fit === "contain" ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih) + const dw = iw * scale + const dh = ih * scale + return { x: (w - dw) / 2, y: (h - dh) / 2, w: dw, h: dh } +} + +/** + * Ordered-dither the image at one pass level into a small canvas, one pixel + * per cell. Drawn back up with smoothing off, each pixel is a crisp cell. + * Returns null if the image taints the canvas (no CORS). + */ +function renderLevel( + image: HTMLImageElement, + w: number, + h: number, + fit: DitherScanFit, + spec: ReturnType, + shadow: [number, number, number], + highlight: [number, number, number] +): HTMLCanvasElement | null { + const sw = Math.max(1, Math.ceil(w / spec.cell)) + const sh = Math.max(1, Math.ceil(h / spec.cell)) + const out = document.createElement("canvas") + out.width = sw + out.height = sh + const ctx = out.getContext("2d", { willReadFrequently: true }) + if (!ctx) return null + + const r = fitRect(image.naturalWidth, image.naturalHeight, w, h, fit) + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + ctx.drawImage( + image, + r.x / spec.cell, + r.y / spec.cell, + r.w / spec.cell, + r.h / spec.cell + ) + + let data: ImageData + try { + data = ctx.getImageData(0, 0, sw, sh) + } catch { + return null + } + + const px = data.data + const steps = spec.tones - 1 + const mix = spec.color + const keep = 1 - mix + const [sr, sg, sb] = shadow + const dr = highlight[0] - sr + const dg = highlight[1] - sg + const db = highlight[2] - sb + + for (let y = 0; y < sh; y++) { + const row = BAYER8[y & 7]! + for (let x = 0; x < sw; x++) { + const i = (y * sw + x) * 4 + if (px[i + 3]! === 0) continue + const bias = (row[x & 7]! + 0.5) / 64 - 0.5 + const r8 = px[i]! + const g8 = px[i + 1]! + const b8 = px[i + 2]! + + const lum = (0.2126 * r8 + 0.7152 * g8 + 0.0722 * b8) / 255 + let tone = Math.round(lum * steps + bias) / steps + tone = tone < 0 ? 0 : tone > 1 ? 1 : tone + + let qr = r8 + let qg = g8 + let qb = b8 + if (mix > 0) { + qr = Math.round((r8 / 255) * steps + bias) / steps + qg = Math.round((g8 / 255) * steps + bias) / steps + qb = Math.round((b8 / 255) * steps + bias) / steps + qr = (qr < 0 ? 0 : qr > 1 ? 1 : qr) * 255 + qg = (qg < 0 ? 0 : qg > 1 ? 1 : qg) * 255 + qb = (qb < 0 ? 0 : qb > 1 ? 1 : qb) * 255 + } + + px[i] = (sr + dr * tone) * keep + qr * mix + px[i + 1] = (sg + dg * tone) * keep + qg * mix + px[i + 2] = (sb + db * tone) * keep + qb * mix + px[i + 3] = 255 + } + } + + ctx.putImageData(data, 0, 0) + return out +} + +const IDLE = 0 +const SWEEP = 1 +const HOLD = 2 +const DONE = 3 +const RESET = 4 + +export function createDitherScan( + wrap: HTMLElement, + canvas: HTMLCanvasElement, + initial: DitherScanOptions +): DitherScanInstance | null { + let options: DitherScanOptions = { + passes: 4, + duration: 1400, + pause: 280, + angle: 45, + alternate: true, + pixelSize: 6, + blur: 6, + fit: "cover", + autoPlay: true, + loop: false, + loopDelay: 2400, + theme: "auto", + ...initial, + } + + const ctx = canvas.getContext("2d") + if (!ctx) return null + + let running = true + let raf = 0 + let rebuildTimer = 0 + let loopTimer = 0 + let cssW = 0 + let cssH = 0 + let dpr = 1 + let reduce = false + let dark = resolveDark(options.theme ?? "auto") + let visible = false + let image: HTMLImageElement | null = null + let loadedSrc = "" + /** `levels[k]` is pass level k; the last is null and draws the source. */ + let levels: (HTMLCanvasElement | null)[] = [] + /** CSS px per level pixel, matching `levels`. */ + let cells: number[] = [] + let levelsW = 0 + let levelsH = 0 + /** Blurred copies of levels, made the first time a sweep needs one. */ + const blurred = new Map() + let scratch: HTMLCanvasElement | null = null + let warmTimer = 0 + let tainted = false + + let state = IDLE + /** Sweeps finished; the frame shows `levels[pass]`. */ + let pass = 0 + let phaseStart = 0 + let wantsPlay = false + let scanning = false + + const passes = () => clampPasses(options.passes) + + const setScanning = (next: boolean) => { + if (scanning === next) return + scanning = next + options.onScanningChange?.(next) + } + + const palette = () => { + const pair = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS) + return [hexRgb(pair[0]), hexRgb(pair[1])] as const + } + + const rebuild = () => { + window.clearTimeout(rebuildTimer) + rebuildTimer = 0 + if (!image || cssW <= 0 || cssH <= 0) return + const n = passes() + const [shadow, highlight] = palette() + const fit = options.fit ?? "cover" + const pixelSize = options.pixelSize ?? 6 + const next: (HTMLCanvasElement | null)[] = [] + const nextCells: number[] = [] + tainted = false + for (let k = 0; k < n; k++) { + const spec = levelSpec(k, n, pixelSize) + const level = renderLevel( + image, + cssW, + cssH, + fit, + spec, + shadow, + highlight + ) + if (!level) { + tainted = true + break + } + next.push(level) + nextCells.push(spec.cell) + } + // A cross-origin image without CORS can't be read: show it as is. + levels = tainted ? [null] : [...next, null] + cells = tainted ? [1] : [...nextCells, 1] + levelsW = cssW + levelsH = cssH + blurred.clear() + warmBlur() + if (pass > levels.length - 1) pass = levels.length - 1 + } + + const drawLevel = (k: number, target: CanvasRenderingContext2D = ctx) => { + const level = levels[k] + if (level === undefined) return + if (level === null) { + if (!image) return + const r = fitRect( + image.naturalWidth, + image.naturalHeight, + cssW, + cssH, + options.fit ?? "cover" + ) + target.imageSmoothingEnabled = true + target.imageSmoothingQuality = "high" + target.drawImage(image, r.x, r.y, r.w, r.h) + return + } + // Stretch to the current size; a resize rebuilds after it settles. + const cell = cells[k] ?? 1 + target.imageSmoothingEnabled = false + target.drawImage( + level, + 0, + 0, + level.width * cell * (cssW / levelsW), + level.height * cell * (cssH / levelsH) + ) + } + + /** + * Level `k` at CSS size, blurred. The sharp copy goes under it so the + * frame's edges don't blur into transparency. Null where canvas filters + * are unsupported. + */ + const blurredLevel = (k: number) => { + if (blurred.has(k)) return blurred.get(k)! + const out = document.createElement("canvas") + out.width = Math.max(1, Math.round(cssW)) + out.height = Math.max(1, Math.round(cssH)) + const bctx = out.getContext("2d") + let result: HTMLCanvasElement | null = null + if (bctx && typeof bctx.filter === "string") { + drawLevel(k, bctx) + bctx.filter = `blur(${options.blur ?? 6}px)` + drawLevel(k, bctx) + bctx.filter = "none" + result = out + } + blurred.set(k, result) + return result + } + + /** + * Blur every level ahead of time, one per task, so a sweep never stalls a + * frame making the copy it needs. + */ + const warmBlur = () => { + window.clearTimeout(warmTimer) + if ((options.blur ?? 6) <= 0 || reduce) return + const step = (k: number) => { + if (!running || k >= levels.length) return + blurredLevel(k) + warmTimer = window.setTimeout(() => step(k + 1), 0) + } + warmTimer = window.setTimeout(() => step(0), 0) + } + + /** + * A feathered band of blur riding the line: the incoming level softens + * behind it and sharpens as the line moves on, the outgoing one just ahead. + */ + const drawScanBlur = (a: number, u: number, e: number, f: number) => { + if ((options.blur ?? 6) <= 0) return + const ahead = blurredLevel(pass) + const behind = blurredLevel(pass + 1) + if (!ahead || !behind) return + + if (!scratch) scratch = document.createElement("canvas") + const w = Math.max(1, Math.round(cssW)) + const h = Math.max(1, Math.round(cssH)) + if (scratch.width !== w || scratch.height !== h) { + scratch.width = w + scratch.height = h + } + const sctx = scratch.getContext("2d") + if (!sctx) return + + sctx.setTransform(1, 0, 0, 1, 0, 0) + sctx.globalCompositeOperation = "source-over" + sctx.clearRect(0, 0, w, h) + sctx.drawImage(ahead, 0, 0) + + sctx.save() + sctx.translate(cssW / 2, cssH / 2) + sctx.rotate(a) + sctx.beginPath() + sctx.rect(-e - 2, -f, u + e + 2, f * 2) + sctx.clip() + sctx.setTransform(1, 0, 0, 1, 0, 0) + sctx.drawImage(behind, 0, 0) + sctx.restore() + + // Keep only the band. The fill covers the whole frame, so the mask + // doesn't depend on how a browser bounds `destination-in`. + sctx.save() + sctx.translate(cssW / 2, cssH / 2) + sctx.rotate(a) + const band = BLUR_BEHIND + BLUR_AHEAD + const peak = BLUR_BEHIND / band + const mask = sctx.createLinearGradient(u - BLUR_BEHIND, 0, u + BLUR_AHEAD, 0) + for (let i = 0; i <= BLUR_STOPS; i++) { + const x = i / BLUR_STOPS + const rise = x * peak + const fall = peak + x * (1 - peak) + mask.addColorStop(rise, `rgba(0,0,0,${smoothstep(x)})`) + mask.addColorStop(fall, `rgba(0,0,0,${smoothstep(1 - x)})`) + } + sctx.globalCompositeOperation = "destination-in" + sctx.fillStyle = mask + sctx.fillRect(-e - band - 4, -f, (e + band + 4) * 2, f * 2) + sctx.restore() + + ctx.drawImage(scratch, 0, 0, cssW, cssH) + } + + /** Draw the line and its trail, rotated to the travel direction. */ + const drawLine = (u: number, f: number) => { + const color = options.lineColor + const trail = ctx.createLinearGradient(u - TRAIL, 0, u, 0) + if (color) { + const [r, g, b] = hexRgb(color) + trail.addColorStop(0, `rgba(${r},${g},${b},0)`) + trail.addColorStop(1, `rgba(${r},${g},${b},0.28)`) + } else { + trail.addColorStop(0, "rgba(255,255,255,0)") + trail.addColorStop(1, "rgba(255,255,255,0.22)") + } + ctx.globalCompositeOperation = color ? "source-over" : "soft-light" + ctx.fillStyle = trail + ctx.fillRect(u - TRAIL, -f, TRAIL, f * 2) + + ctx.globalCompositeOperation = color ? "source-over" : "difference" + ctx.fillStyle = color ?? "#FFFFFF" + ctx.fillRect(u - LINE_WIDTH / 2, -f, LINE_WIDTH, f * 2) + ctx.globalCompositeOperation = "source-over" + } + + const draw = (now: number) => { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, cssW, cssH) + if (!image || levels.length === 0) return + + if (state === RESET) { + const t = Math.min(1, (now - phaseStart) / RESET_MS) + drawLevel(levels.length - 1) + ctx.globalAlpha = easeInOutSine(t) + drawLevel(0) + ctx.globalAlpha = 1 + return + } + + drawLevel(pass) + if (state !== SWEEP) return + + const t = easeInOutSine( + Math.min(1, (now - phaseStart) / Math.max(1, options.duration ?? 1400)) + ) + const flip = options.alternate !== false && pass % 2 === 1 + const a = (((options.angle ?? 45) + (flip ? 180 : 0)) * Math.PI) / 180 + const cos = Math.abs(Math.cos(a)) + const sin = Math.abs(Math.sin(a)) + // Half-extents of the frame along the travel axis (e) and across it (f). + const e = (cos * cssW + sin * cssH) / 2 + const f = (sin * cssW + cos * cssH) / 2 + 2 + // With blur on, travel until the band has fully entered and left the + // frame, so it never pops in or out at a pass boundary. + const soft = (options.blur ?? 6) > 0 + const from = -e - LINE_WIDTH - (soft ? BLUR_AHEAD : 0) + const to = e + LINE_WIDTH + (soft ? BLUR_BEHIND : 0) + const u = from + (to - from) * t + + ctx.save() + ctx.translate(cssW / 2, cssH / 2) + ctx.rotate(a) + ctx.beginPath() + ctx.rect(-e - 2, -f, u + e + 2, f * 2) + ctx.clip() + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + drawLevel(pass + 1) + ctx.restore() + + drawScanBlur(a, u, e, f) + + ctx.save() + ctx.translate(cssW / 2, cssH / 2) + ctx.rotate(a) + drawLine(u, f) + ctx.restore() + } + + const scheduleLoop = () => { + window.clearTimeout(loopTimer) + if (!options.loop || reduce || levels.length < 2) return + loopTimer = window.setTimeout(() => { + if (!running || state !== DONE) return + state = RESET + phaseStart = performance.now() + setScanning(true) + startLoop() + }, Math.max(0, options.loopDelay ?? 2400)) + } + + const finish = () => { + state = DONE + pass = levels.length - 1 + setScanning(false) + options.onComplete?.() + scheduleLoop() + } + + const tick = (now: number) => { + raf = 0 + if (!running) return + const last = levels.length - 1 + + if (state === SWEEP) { + if (now - phaseStart >= Math.max(1, options.duration ?? 1400)) { + pass += 1 + options.onPass?.(pass, last) + if (pass >= last) { + finish() + } else { + state = HOLD + phaseStart = now + } + } + } else if (state === HOLD) { + if (now - phaseStart >= Math.max(0, options.pause ?? 280)) { + state = SWEEP + phaseStart = now + } + } else if (state === RESET) { + if (now - phaseStart >= RESET_MS) { + state = IDLE + pass = 0 + begin(now) + } + } + + draw(now) + if (state === SWEEP || state === HOLD || state === RESET) { + raf = requestAnimationFrame(tick) + } + } + + const startLoop = () => { + if (!raf && running) raf = requestAnimationFrame(tick) + } + + const redraw = () => { + if (raf) return + draw(performance.now()) + } + + /** Start sweeping from the current level, once there is something to show. */ + const begin = (now = performance.now()) => { + wantsPlay = false + const last = levels.length - 1 + if (last <= 0 || reduce) { + pass = Math.max(0, last) + state = DONE + redraw() + if (last >= 0) options.onComplete?.() + return + } + if (pass === 0) options.onStart?.() + state = SWEEP + phaseStart = now + setScanning(true) + startLoop() + } + + const requestPlay = () => { + if (!image || levels.length === 0 || !visible) { + wantsPlay = true + return + } + begin() + } + + const restart = (play: boolean) => { + window.clearTimeout(loopTimer) + cancelAnimationFrame(raf) + raf = 0 + state = IDLE + pass = 0 + setScanning(false) + redraw() + if (play) requestPlay() + } + + /** Returns whether the CSS size changed. */ + const resize = () => { + dpr = Math.min(window.devicePixelRatio || 1, 2) + const w = wrap.clientWidth + const h = wrap.clientHeight + if (w <= 0 || h <= 0) return false + const changed = w !== cssW || h !== cssH + cssW = w + cssH = h + const bw = Math.max(1, Math.floor(w * dpr)) + const bh = Math.max(1, Math.floor(h * dpr)) + if (bw !== canvas.width || bh !== canvas.height) { + canvas.width = bw + canvas.height = bh + } + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + return changed + } + + const load = (src: string) => { + loadedSrc = src + levels = [] + image = null + restart(false) + if (!src) return + const img = new Image() + img.crossOrigin = "anonymous" + img.decoding = "async" + img.onload = () => { + if (!running || loadedSrc !== src) return + image = img + options.onLoad?.(img.naturalWidth, img.naturalHeight) + resize() + rebuild() + redraw() + if (options.autoPlay !== false || wantsPlay) requestPlay() + } + img.src = src + } + + resize() + + const ro = new ResizeObserver(() => { + if (!resize() || !image) return + if (levels.length === 0) rebuild() + redraw() + window.clearTimeout(rebuildTimer) + rebuildTimer = window.setTimeout(() => { + rebuild() + redraw() + if (wantsPlay) requestPlay() + }, REBUILD_DELAY_MS) + }) + ro.observe(wrap) + + const io = new IntersectionObserver( + (entries) => { + visible = entries.some((entry) => entry.isIntersecting) + if (visible && wantsPlay) requestPlay() + }, + { threshold: 0.35 } + ) + io.observe(wrap) + + 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 + if (!options.colors) { + rebuild() + redraw() + } + } + 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) + + wantsPlay = options.autoPlay !== false + load(options.src) + + return { + setOptions(next) { + const prev = options + options = { ...options, ...next } + if (options.src !== loadedSrc) { + wantsPlay = options.autoPlay !== false + load(options.src) + return + } + const passesChanged = clampPasses(prev.passes) !== passes() + const looks = + passesChanged || + prev.pixelSize !== options.pixelSize || + prev.fit !== options.fit || + prev.colors?.[0] !== options.colors?.[0] || + prev.colors?.[1] !== options.colors?.[1] + if (prev.theme !== options.theme) { + dark = resolveDark(options.theme ?? "auto") + } + if (looks || prev.theme !== options.theme) rebuild() + else if (prev.blur !== options.blur) { + blurred.clear() + warmBlur() + } + if (passesChanged) { + restart(options.autoPlay !== false) + return + } + if (prev.loop !== options.loop && state === DONE) scheduleLoop() + if (prev.autoPlay === false && options.autoPlay !== false && state === IDLE) { + requestPlay() + } + redraw() + }, + replay() { + restart(true) + }, + destroy() { + running = false + cancelAnimationFrame(raf) + window.clearTimeout(rebuildTimer) + window.clearTimeout(loopTimer) + window.clearTimeout(warmTimer) + ro.disconnect() + io.disconnect() + mo.disconnect() + mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) + options.onScanningChange?.(false) + }, + } +} diff --git a/registry/dither-scan/dither-scan.tsx b/registry/dither-scan/dither-scan.tsx new file mode 100644 index 0000000..82aa4c0 --- /dev/null +++ b/registry/dither-scan/dither-scan.tsx @@ -0,0 +1,159 @@ +"use client" + +import { useEffect, useRef, useState, type CSSProperties } from "react" + +import { cn } from "@/lib/utils" + +import { + createDitherScan, + type DitherScanInstance, + type DitherScanOptions, +} from "./dither-scan-vanilla" + +export { DARK_COLORS, LIGHT_COLORS, levelSpec } from "./dither-scan-vanilla" +export type { + DitherScanFit, + DitherScanInstance, + DitherScanOptions, + DitherScanTheme, +} from "./dither-scan-vanilla" + +export type DitherScanProps = Omit< + DitherScanOptions, + "onLoad" | "onScanningChange" +> & { + /** Accessible description of the image. */ + alt?: string + /** Increment to re-dither and scan again. */ + replayKey?: number + className?: string +} + +/** + * An image that starts as a 1-bit ordered dither. A diagonal scan line + * sweeps it `passes` times; each sweep sharpens the cells and returns tone + * and color, and the last one leaves the source image. + */ +export function DitherScan({ + src, + alt = "", + passes = 4, + duration = 1400, + pause = 280, + angle = 45, + alternate = true, + pixelSize = 6, + blur = 6, + colors, + lineColor, + fit = "cover", + autoPlay = true, + loop = false, + loopDelay = 2400, + theme = "auto", + replayKey = 0, + onStart, + onPass, + onComplete, + className, +}: DitherScanProps) { + const wrapRef = useRef(null) + const canvasRef = useRef(null) + const instanceRef = useRef(null) + const replayKeyRef = useRef(replayKey) + const callbacksRef = useRef({ onStart, onPass, onComplete }) + const [ratio, setRatio] = useState(null) + const [scanning, setScanning] = useState(false) + + useEffect(() => { + callbacksRef.current = { onStart, onPass, onComplete } + }, [onStart, onPass, onComplete]) + + const shadow = colors?.[0] + const highlight = colors?.[1] + + const options = { + src, + passes, + duration, + pause, + angle, + alternate, + pixelSize, + blur, + colors, + lineColor, + fit, + autoPlay, + loop, + loopDelay, + theme, + } + + useEffect(() => { + const wrap = wrapRef.current + const canvas = canvasRef.current + if (!wrap || !canvas) return + instanceRef.current = createDitherScan(wrap, canvas, { + ...options, + onLoad: (w, h) => setRatio(`${w} / ${h}`), + onScanningChange: setScanning, + onStart: () => callbacksRef.current.onStart?.(), + onPass: (pass, total) => callbacksRef.current.onPass?.(pass, total), + onComplete: () => callbacksRef.current.onComplete?.(), + }) + 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(options) + // `options` is rebuilt every render; its fields are the real deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + src, + passes, + duration, + pause, + angle, + alternate, + pixelSize, + blur, + shadow, + highlight, + lineColor, + fit, + autoPlay, + loop, + loopDelay, + theme, + ]) + + useEffect(() => { + if (replayKeyRef.current === replayKey) return + replayKeyRef.current = replayKey + instanceRef.current?.replay() + }, [replayKey]) + + return ( +
+ +
+ ) +} diff --git a/registry/dither-scan/registry.json b/registry/dither-scan/registry.json new file mode 100644 index 0000000..ebf5cd5 --- /dev/null +++ b/registry/dither-scan/registry.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "dither-scan", + "type": "registry:ui", + "title": "Dither Scan", + "description": "An image that starts as a 1-bit ordered dither. A diagonal scan line sweeps it a set number of times, and each pass sharpens the cells and brings back tone and color until the source is clean.", + "files": [ + { + "path": "dither-scan.tsx", + "type": "registry:ui", + "target": "components/ui/dither-scan.tsx" + } + ] + } + ] +} diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index d8d2283..6ddddaf 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -72,6 +72,7 @@ flowchart TD pick -->|Pick or edit a color| gooey[gooey-color-picker] pick -->|Atmosphere behind a hero| atmo{Mood} pick -->|Footer or page-end| foot{Treatment} + pick -->|Reveal an image from a dither| ds[dither-scan] pick -->|Full page 404| d404[dithered-404] pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} @@ -100,6 +101,7 @@ Disambiguation that agents get wrong: | Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | | Fire, 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` | +| Image that un-dithers, scan line, enhance or processing reveal | `dither-scan` | `dithered-404` (a 404 glyph, not your image) | | 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` | @@ -182,6 +184,7 @@ Categories match `content/docs/components/meta.json`. | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | | `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | +| `dither-scan` | Components | 1-bit dithered image a diagonal scan line clears in N passes | ## Minimal installs that must be right diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 5df5ae6..51b14ab 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -382,6 +382,36 @@ type Dithered404Props = { `LIGHT_COLOR` `#18181B`, `DARK_COLOR` `#E4E4E7`. `dither={false}` is soft fire on the same glyph. No `text` prop. Wrapper does not expose `onHideCursor`. +## dither-scan + +```ts +type DitherScanProps = { + src: string + alt?: string + passes?: number // 4, clamped 1–12 + duration?: number // 1400 ms per pass + pause?: number // 280 ms between passes + angle?: number // 45, degrees the line travels (0 = left to right) + alternate?: boolean // true, odd passes run back + pixelSize?: number // 6 CSS px, first-pass cell + blur?: number // 6 CSS px trailing the line, 0 off + colors?: readonly [shadow: string, highlight: string] // hex + lineColor?: string // default inverts what is under the line + fit?: "cover" | "contain" // "cover" + autoPlay?: boolean // true, starts when 35% in view + loop?: boolean // false + loopDelay?: number // 2400 ms on the clean image + theme?: "light" | "dark" | "auto" + replayKey?: number // increment to re-dither and scan + onStart?: () => void + onPass?: (pass: number, passes: number) => void + onComplete?: () => void + className?: string +} +``` + +`LIGHT_COLORS` `["#18181B", "#F4F4F5"]`, `DARK_COLORS` `["#09090B", "#D4D4D8"]`. Also exports `levelSpec(level, passes, pixelSize)`. Vanilla `onLoad` and `onScanningChange` are internal to the wrapper, which sets `data-scanning` and `aria-busy` while sweeping. + ## folio ```ts diff --git a/skills/23rd/components.md b/skills/23rd/components.md index a9b9260..98b3c2b 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -382,6 +382,34 @@ export function NotFound() { Pitfalls: `interactive` hides the system cursor while the fireball is active (`onHideCursor` exists on the vanilla options; the React wrapper owns that state and does not expose the callback as a prop). Ink follows theme: `#18181B` light, `#E4E4E7` dark. The glyph string is the 404 itself; there is no `text` prop. +## dither-scan + +Component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. + +Best fit: a cover image, product shot, or case-study hero that should feel processed or "enhanced" as it arrives. One per view. + +Not this: a 404 page (`dithered-404`), a background wash (`shader-gradient`), or an image gallery. It plays once per scroll into view unless `loop` is set. + +Deps: none. Key props: `src`, `alt`, `passes` (4), `duration` (1400), `angle` (45), `alternate` (true), `pixelSize` (6), `blur` (6), `autoPlay` (true), `loop` (false), `replayKey`, `onPass`, `onComplete`. + +```tsx +"use client" +import { DitherScan } from "@/components/ui/dither-scan" + +export function Cover() { + return ( + + ) +} +``` + +Pitfalls: remote images must send CORS headers; an unreadable image is shown clean with no scan. The frame takes the image's aspect ratio and full width; set height and width in `className` to crop instead (`fit` picks cover or contain). Reduced motion shows the clean image immediately. + ## folio Section. The sheet tips one way on scroll down, the other on scroll up, blur on the traveling edge, then springs flat. Peak tilt is an internal 16°. From 230305686bca8933e9cfe08cd03df0fb17dfd21a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 20:52:52 +0530 Subject: [PATCH 3/4] Give Dither Scan its own AI Components group in the docs. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/SKILL.md | 2 +- .cursor/skills/23rd/components.md | 2 +- .cursor/skills/23rd/conventions.md | 2 +- content/docs/components/meta.json | 3 ++- content/docs/getting-started.mdx | 21 +++++++++++---------- skills/23rd/SKILL.md | 2 +- skills/23rd/components.md | 2 +- skills/23rd/conventions.md | 2 +- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index 6ddddaf..c9db98a 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -184,7 +184,7 @@ Categories match `content/docs/components/meta.json`. | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | | `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | -| `dither-scan` | Components | 1-bit dithered image a diagonal scan line clears in N passes | +| `dither-scan` | AI Components | 1-bit dithered image a diagonal scan line clears in N passes | ## Minimal installs that must be right diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index 98b3c2b..6d8304c 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -384,7 +384,7 @@ Pitfalls: `interactive` hides the system cursor while the fireball is active (`o ## dither-scan -Component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. +AI component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. Best fit: a cover image, product shot, or case-study hero that should feel processed or "enhanced" as it arrives. One per view. diff --git a/.cursor/skills/23rd/conventions.md b/.cursor/skills/23rd/conventions.md index e5a7275..4d26367 100644 --- a/.cursor/skills/23rd/conventions.md +++ b/.cursor/skills/23rd/conventions.md @@ -117,7 +117,7 @@ import { Foo } from "@/components/ui/" | --- | --- | --- | ``` -Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components. Separators look like `"---Background---"`. +Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components, AI Components, Experiments. Separators look like `"---Background---"`. MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`. diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index c6660d6..35eac1c 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -22,9 +22,10 @@ "---Sections---", "folio", "---Components---", - "dither-scan", "gooey-color-picker", "mech-keyboard", + "---AI Components---", + "dither-scan", "---Experiments---", "phosphor-score", "typewriter" diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index 799f89b..278701d 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -36,13 +36,14 @@ export function Hero() { ## Browse -| Category | Components | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Background | [Logo Burst](/docs/components/logo-burst), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid), [Ring Tower](/docs/components/ring-tower), [Iso Lattice](/docs/components/iso-lattice) | -| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky), [Shader Metal](/docs/components/shader-metal) | -| Footers | [Stretchy Footer](/docs/components/stretchy-footer) | -| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | -| Pages | [Dithered 404](/docs/components/dithered-404) | -| Sections | [Folio](/docs/components/folio) | -| Components | [Dither Scan](/docs/components/dither-scan), [Gooey Color Picker](/docs/components/gooey-color-picker) | -| Experiments | [Phosphor Score](/docs/components/phosphor-score), [Typewriter](/docs/components/typewriter) | +| Category | Components | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Background | [Logo Burst](/docs/components/logo-burst), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid), [Ring Tower](/docs/components/ring-tower), [Iso Lattice](/docs/components/iso-lattice) | +| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky), [Shader Metal](/docs/components/shader-metal) | +| Footers | [Stretchy Footer](/docs/components/stretchy-footer) | +| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | +| Pages | [Dithered 404](/docs/components/dithered-404) | +| Sections | [Folio](/docs/components/folio) | +| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | +| AI Components | [Dither Scan](/docs/components/dither-scan) | +| Experiments | [Phosphor Score](/docs/components/phosphor-score), [Typewriter](/docs/components/typewriter) | diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index 6ddddaf..c9db98a 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -184,7 +184,7 @@ Categories match `content/docs/components/meta.json`. | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | | `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | -| `dither-scan` | Components | 1-bit dithered image a diagonal scan line clears in N passes | +| `dither-scan` | AI Components | 1-bit dithered image a diagonal scan line clears in N passes | ## Minimal installs that must be right diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 98b3c2b..6d8304c 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -384,7 +384,7 @@ Pitfalls: `interactive` hides the system cursor while the fireball is active (`o ## dither-scan -Component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. +AI component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. Best fit: a cover image, product shot, or case-study hero that should feel processed or "enhanced" as it arrives. One per view. diff --git a/skills/23rd/conventions.md b/skills/23rd/conventions.md index e5a7275..4d26367 100644 --- a/skills/23rd/conventions.md +++ b/skills/23rd/conventions.md @@ -117,7 +117,7 @@ import { Foo } from "@/components/ui/" | --- | --- | --- | ``` -Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components. Separators look like `"---Background---"`. +Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components, AI Components, Experiments. Separators look like `"---Background---"`. MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`. From bc0e7852b19fdbb0b4202b2d29308d7a7332f735 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 20:53:06 +0530 Subject: [PATCH 4/4] List AI Components first in the docs, then Background and Shaders. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/SKILL.md | 2 +- .cursor/skills/23rd/conventions.md | 2 +- content/docs/components/meta.json | 4 ++-- content/docs/getting-started.mdx | 2 +- skills/23rd/SKILL.md | 2 +- skills/23rd/conventions.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index c9db98a..d254159 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -166,6 +166,7 @@ Categories match `content/docs/components/meta.json`. | Name | Category | One line | | --- | --- | --- | +| `dither-scan` | AI Components | 1-bit dithered image a diagonal scan line clears in N passes | | `logo-burst` | Background | Hair-line tentacles explode from center, then breathe | | `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | @@ -184,7 +185,6 @@ Categories match `content/docs/components/meta.json`. | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | | `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | -| `dither-scan` | AI Components | 1-bit dithered image a diagonal scan line clears in N passes | ## Minimal installs that must be right diff --git a/.cursor/skills/23rd/conventions.md b/.cursor/skills/23rd/conventions.md index 4d26367..b7ba91f 100644 --- a/.cursor/skills/23rd/conventions.md +++ b/.cursor/skills/23rd/conventions.md @@ -117,7 +117,7 @@ import { Foo } from "@/components/ui/" | --- | --- | --- | ``` -Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components, AI Components, Experiments. Separators look like `"---Background---"`. +Add the page under a separator in `content/docs/components/meta.json`. Current groups, in order: AI Components, Background, Shaders, Footers, Characters, Pages, Sections, Components, Experiments. Separators look like `"---Background---"`. MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`. diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index 35eac1c..d035e50 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -1,6 +1,8 @@ { "title": "Components", "pages": [ + "---AI Components---", + "dither-scan", "---Background---", "logo-burst", "radiant-lines", @@ -24,8 +26,6 @@ "---Components---", "gooey-color-picker", "mech-keyboard", - "---AI Components---", - "dither-scan", "---Experiments---", "phosphor-score", "typewriter" diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index 278701d..b298ca4 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -38,6 +38,7 @@ export function Hero() { | Category | Components | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AI Components | [Dither Scan](/docs/components/dither-scan) | | Background | [Logo Burst](/docs/components/logo-burst), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid), [Ring Tower](/docs/components/ring-tower), [Iso Lattice](/docs/components/iso-lattice) | | Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky), [Shader Metal](/docs/components/shader-metal) | | Footers | [Stretchy Footer](/docs/components/stretchy-footer) | @@ -45,5 +46,4 @@ export function Hero() { | Pages | [Dithered 404](/docs/components/dithered-404) | | Sections | [Folio](/docs/components/folio) | | Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | -| AI Components | [Dither Scan](/docs/components/dither-scan) | | Experiments | [Phosphor Score](/docs/components/phosphor-score), [Typewriter](/docs/components/typewriter) | diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index c9db98a..d254159 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -166,6 +166,7 @@ Categories match `content/docs/components/meta.json`. | Name | Category | One line | | --- | --- | --- | +| `dither-scan` | AI Components | 1-bit dithered image a diagonal scan line clears in N passes | | `logo-burst` | Background | Hair-line tentacles explode from center, then breathe | | `phosphor-score` | Background | Vertical CRT score; notes fall, bloom, flare | | `radiant-lines` | Background | Hyperspace streaks; warp follows scroll | @@ -184,7 +185,6 @@ Categories match `content/docs/components/meta.json`. | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | | `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | -| `dither-scan` | AI Components | 1-bit dithered image a diagonal scan line clears in N passes | ## Minimal installs that must be right diff --git a/skills/23rd/conventions.md b/skills/23rd/conventions.md index 4d26367..b7ba91f 100644 --- a/skills/23rd/conventions.md +++ b/skills/23rd/conventions.md @@ -117,7 +117,7 @@ import { Foo } from "@/components/ui/" | --- | --- | --- | ``` -Add the page under a separator in `content/docs/components/meta.json`. Current groups: Background, Shaders, Footers, Characters, Pages, Sections, Components, AI Components, Experiments. Separators look like `"---Background---"`. +Add the page under a separator in `content/docs/components/meta.json`. Current groups, in order: AI Components, Background, Shaders, Footers, Characters, Pages, Sections, Components, Experiments. Separators look like `"---Background---"`. MDX components already global: `CliCommand`, `ComponentPreview`, `ComponentControls`.