diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 2e04ec7..8bb0641 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -276,6 +276,7 @@ type StretchyFooterProps = { glow?: number // 0, white floor bloom, 0–1 flip?: boolean // false; edge columns tallest, middle dips rotate?: 0 | 180 // 0; 180 hangs the aurora from the lifted page + peek?: number // 0; resting stretch, 0–1 of maxStretch, so the aurora shows before any scroll label?: string // "Stretchy overflow" demoId?: string } @@ -426,6 +427,7 @@ type DitherScanProps = { autoPlay?: boolean // true, starts when 35% in view loop?: boolean // false loopDelay?: number // 2400 ms on the clean image + reducedMotionProgress?: number // 0–1, reduced motion holds the line here; unset = clean theme?: "light" | "dark" | "auto" replayKey?: number // increment to re-dither and scan onStart?: () => void @@ -501,6 +503,7 @@ type MechKeyboardProps = { interactive?: boolean // true: window keys and clicks press keys recorder?: boolean // true: illustrated keycaps spelling out the chord placeholder?: string // "Press a shortcut" + defaultShortcut?: string[] // codes the recorder starts on, shown as saved highlight?: string[] // KeyboardEvent.code values to pulse platform?: "auto" | "mac" | "pc" // "auto": bottom-row legends and labels sound?: boolean // false: synthesized switch sounds diff --git a/21st/dither-scan.demo.tsx b/21st/dither-scan.demo.tsx new file mode 100644 index 0000000..caeff36 --- /dev/null +++ b/21st/dither-scan.demo.tsx @@ -0,0 +1,185 @@ +"use client" + +import { useState } from "react" +import { RotateCcw } from "lucide-react" + +import { DitherScan } from "./dither-scan" + +const settings = { + passes: 4, + duration: 1400, + pixelSize: 6, + blur: 6, + angle: 45, + alternate: true, + loop: true, +} + +const W = 1500 +const H = 1000 + +/** A seeded random stream, so the scene is the same on every load. */ +function seeded(seed: number) { + let s = seed + return () => { + s = (s * 16807) % 2147483647 + return s / 2147483647 + } +} + +/** + * A mountain ridge: a valley around `center` (0–1 across) that climbs + * `depth` px toward the sides, roughened by midpoint displacement. + */ +function ridge( + seed: number, + base: number, + depth: number, + amp: number, + center = 0.5 +) { + const rand = seeded(seed) + const n = 128 + const noise = new Array(n + 1).fill(0) + const split = (a: number, b: number, h: number) => { + if (b - a < 2) return + const m = (a + b) >> 1 + noise[m] = (noise[a]! + noise[b]!) / 2 + (rand() - 0.5) * h + split(a, m, h * 0.6) + split(m, b, h * 0.6) + } + noise[0] = (rand() - 0.5) * amp + noise[n] = (rand() - 0.5) * amp + split(0, n, amp) + const pts = noise.map((v, i) => { + const t = i / n + const side = Math.min( + 1, + Math.abs(t - center) / Math.max(center, 1 - center) + ) + const y = base - depth * side ** 1.7 + v + return `${(t * W).toFixed(1)},${y.toFixed(1)}` + }) + return `M0,${H} L${pts.join(" L")} L${W},${H} Z` +} + +/** A stand of pines between `from` and `to`, tallest at the outer edge. */ +function pines(seed: number, from: number, to: number, y: number, h: number) { + const rand = seeded(seed) + let d = "" + const count = 16 + for (let i = 0; i < count; i++) { + const t = i / (count - 1) + const x = from + t * (to - from) + (rand() - 0.5) * 16 + const th = h * (1 - 0.55 * t) * (0.75 + rand() * 0.4) + const w = th * 0.2 + const b = y + rand() * 10 + const top = b - th + // Three tiers, so each tree reads as a pine, not a spike. + for (let k = 0; k < 3; k++) { + const ty = top + (th * k) / 4 + const by = top + (th * (k + 2)) / 4 + const bw = w * (0.6 + k * 0.25) + d += `M${x.toFixed(1)},${ty.toFixed(1)} L${(x + bw).toFixed(1)},${by.toFixed(1)} L${(x - bw).toFixed(1)},${by.toFixed(1)} Z ` + } + d += `M${(x - 3).toFixed(1)},${(b - th / 4).toFixed(1)} h6 V${(b + 4).toFixed(1)} h-6 Z ` + } + return d +} + +const LAYERS = [ + { d: ridge(11, 640, 300, 190, 0.5), top: "#EFA3A3", bottom: "#F8C6A9" }, + { d: ridge(23, 700, 230, 150, 0.36), top: "#BE6489", bottom: "#DE8D93" }, + { d: ridge(37, 745, 170, 110, 0.64), top: "#7E3E75", bottom: "#A85A82" }, + { d: ridge(41, 780, 100, 60, 0.5), top: "#46244F", bottom: "#6B3567" }, +] + +const starRand = seeded(5) +const STARS = Array.from({ length: 70 }, () => { + const rand = starRand + const x = rand() * W + const y = rand() * 330 + const r = 0.8 + rand() * 1.6 + return `` +}).join("") + +const GLARE = Array.from({ length: 16 }, (_, i) => { + const w = 250 - i * 12 + ((i * 37) % 34) + const x = 750 - w / 2 + ((i * 53) % 23) - 11 + return `` +}).join("") + +const SCENE = ` + + + + + + + + + + +${LAYERS.map((l, i) => ``).join("")} + + + + + + +${STARS} + + + + + +${LAYERS.map((l, i) => ``).join("")} + +${GLARE} + + + + + +` + +const SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SCENE)}` + +export default function DitherScanDemo(props: Partial) { + const s = { ...settings, ...props } + const [replayKey, setReplayKey] = useState(0) + + return ( +
+
+ +
+ +
+
+
+ ) +} diff --git a/21st/iso-lattice.demo.tsx b/21st/iso-lattice.demo.tsx new file mode 100644 index 0000000..a0cc569 --- /dev/null +++ b/21st/iso-lattice.demo.tsx @@ -0,0 +1,40 @@ +"use client" + +import { IsoLattice, LIGHT_COLOR, type IsoLatticeShape } from "./iso-lattice" + +const settings = { + color: "#C2560C", + speed: 1, + size: 0.8, + glow: 0.6, + pillars: true, + steps: true, + cube: true, + coil: true, +} + +// The bar chart leads: it is the figure that holds under reduced motion. +const ORDER: IsoLatticeShape[] = ["pillars", "steps", "cube", "coil"] + +export default function IsoLatticeDemo(props: Partial) { + const s = { ...settings, ...props } + const on = { pillars: s.pillars, steps: s.steps, cube: s.cube, coil: s.coil } + const picked = ORDER.filter((shape) => on[shape]) + const shapes = picked.length ? picked : ORDER + + // The stock orange follows the theme (burnt on light, amber on dark). + const color = + s.color.trim().toUpperCase() === LIGHT_COLOR ? undefined : s.color + + return ( +
+ +
+ ) +} diff --git a/21st/mech-keyboard.demo.tsx b/21st/mech-keyboard.demo.tsx new file mode 100644 index 0000000..e098742 --- /dev/null +++ b/21st/mech-keyboard.demo.tsx @@ -0,0 +1,52 @@ +"use client" + +import { useEffect, useState } from "react" + +import { MechKeyboard } from "./mech-keyboard" + +const SHORTCUT = ["MetaLeft", "KeyK"] + +const settings = { + scale: 1, + platform: "mac", + monochrome: false, + recorder: true, + hint: true, + interactive: true, + sound: false, +} + +/** On wide stages the board fits by height; grow it into the spare width. */ +function useWideBoost() { + const [boost, setBoost] = useState(1) + useEffect(() => { + const measure = () => { + const aspect = window.innerWidth / Math.max(1, window.innerHeight) + setBoost(Math.min(1.2, Math.max(1, aspect / 1.45))) + } + measure() + window.addEventListener("resize", measure) + return () => window.removeEventListener("resize", measure) + }, []) + return boost +} + +export default function MechKeyboardDemo(props: Partial) { + const s = { ...settings, ...props } + const boost = useWideBoost() + + return ( +
+ +
+ ) +} diff --git a/21st/prompt-box.demo.tsx b/21st/prompt-box.demo.tsx new file mode 100644 index 0000000..76cd953 --- /dev/null +++ b/21st/prompt-box.demo.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useReducedMotion } from "motion/react" + +import { + PromptBox, + PromptBoxAction, + type PromptBoxAttachment, + type PromptBoxMessage, +} from "./prompt-box" + +const settings = { + images: 2, + uploading: true, + busy: true, + draft: "Try these two for the back cover", + placeholder: "Ask anything, or drop images…", + minRows: 1, +} + +/** A flat landscape as an SVG data URI: sky, sun, three ridges, maybe water. */ +function scene( + w: number, + h: number, + sky: [string, string], + sun: string, + ridges: [string, string, string], + water?: string +) { + const ridge = (y: number, amp: number, phase: number, fill: string) => { + let d = `M0 ${h} L0 ${y}` + const steps = 6 + for (let i = 1; i <= steps; i++) { + const x = (w / steps) * i + const cx = x - w / steps / 2 + const cy = y - amp * Math.sin(i * 1.7 + phase) + d += ` Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x.toFixed(1)} ${(y - amp * 0.35 * Math.cos(i + phase)).toFixed(1)}` + } + return `` + } + const svg = ` + + + +${ridge(h * 0.56, h * 0.09, 0.4, ridges[0])} +${ridge(h * 0.68, h * 0.07, 2.1, ridges[1])} +${water ? `` : ridge(h * 0.8, h * 0.05, 4.2, ridges[2])} +` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} + +const PHOTOS = { + ridge: scene(400, 300, ["#8FB3D9", "#F6C9A0"], "#FFF1D6", ["#7C93B8", "#56708F", "#2F4560"]), + lake: scene(400, 300, ["#CFE8F2", "#F4F1E6"], "#FFFDF5", ["#9DB8A8", "#6E9480", "#3F6650"], "#9CC4D2"), + dunes: scene(400, 300, ["#F7B98B", "#FCE3C2"], "#FFF6E8", ["#E7A36B", "#C97B4A", "#9C5634"]), + coast: scene(300, 400, ["#BFD9EA", "#EAF2F5"], "#FFFFFF", ["#7E9C8C", "#4E6B5C", "#2F4A3E"], "#6FA3C4"), + meadow: scene(400, 300, ["#DCE9C9", "#F6F3DA"], "#FFFBEA", ["#A9C38C", "#7FA266", "#55783F"]), + glacier: scene(400, 300, ["#D8E4F0", "#F3F6FA"], "#FFFFFF", ["#B7C7D9", "#8EA4BD", "#61788F"], "#C9DCE8"), +} + +/** Images ready to attach, in the order the `images` knob adds them. */ +const DRAFT_IMAGES: PromptBoxAttachment[] = [ + { id: "dunes", name: "dunes-sunset.jpg", url: PHOTOS.dunes }, + { id: "coast", name: "north-coast.jpg", url: PHOTOS.coast }, + { id: "meadow", name: "meadow.jpg", url: PHOTOS.meadow }, + { id: "glacier", name: "glacier-bay.jpg", url: PHOTOS.glacier }, + { id: "ridge-2", name: "ridge-dawn.jpg", url: PHOTOS.ridge }, + { id: "lake-2", name: "lake-still.jpg", url: PHOTOS.lake }, +] + +type Message = { + id: string + role: "user" | "assistant" + text: string + images?: { name: string; url: string }[] +} + +const THREAD: Message[] = [ + { + id: "m1", + role: "user", + text: "Which of these should be the cover of the trail guide?", + images: [ + { name: "ridge.jpg", url: PHOTOS.ridge }, + { name: "lake.jpg", url: PHOTOS.lake }, + ], + }, + { + id: "m2", + role: "assistant", + text: "The ridge. The layered hills lead the eye up to the title, and the open sky leaves clean room for type. The lake is lovely, but the water fights the cover copy.", + }, + { id: "m3", role: "user", text: "Great. Make a warmer, golden-hour version of it." }, +] + +const REPLIES = [ + "Here's a warmer take: the sky pushed toward amber, the far ridge lifted so the title still sits clear.", + "Done. I kept the horizon where it was so the layout doesn't shift.", + "Both work. The first holds up better small, on the spine.", +] + +/** How long a reply keeps the prompt busy, and how often uploads tick. */ +const REPLY_MS = 2600 +const TICK_MS = 180 + +export default function PromptBoxDemo(props: Partial) { + const s = { ...settings, ...props } + const count = Math.max(0, Math.min(DRAFT_IMAGES.length, Math.round(s.images))) + // Knobs reset the scene: the draft, its sample images and the busy reply. + return ( + + ) +} + +/** The sample images, the last one still uploading. */ +function draftFiles(count: number, uploading: boolean) { + return DRAFT_IMAGES.slice(0, count).map((file, i) => + uploading && i === count - 1 ? { ...file, progress: 0.62 } : file + ) +} + +function PromptBoxScene({ s, count }: { s: typeof settings; count: number }) { + const reduce = useReducedMotion() + + const [thread, setThread] = useState(THREAD) + const [replying, setReplying] = useState(s.busy) + const [text, setText] = useState(s.draft) + const [files, setFiles] = useState(() => draftFiles(count, s.uploading)) + const known = useRef(new Set(files.map((file) => file.id)) as Set) + const replies = useRef(0) + const scroller = useRef(null as HTMLDivElement | null) + + // Keep the newest message in view. + useEffect(() => { + const el = scroller.current + if (el) el.scrollTop = el.scrollHeight + }, [thread, replying]) + + // Uploads tick up, then turn ready. Reduced motion holds the frame. + useEffect(() => { + if (reduce || !files.some((file) => file.progress !== undefined)) return + const id = window.setTimeout(() => { + setFiles((list) => + list.map((file) => { + if (file.progress === undefined) return file + const progress = file.progress + 0.03 + Math.random() * 0.05 + return { ...file, progress: progress >= 1 ? undefined : progress } + }) + ) + }, TICK_MS) + return () => window.clearTimeout(id) + }, [files, reduce]) + + // A sent message gets a reply after a moment. + useEffect(() => { + if (!replying || thread[thread.length - 1]?.role !== "user") return + if (thread.length === THREAD.length) return + const id = window.setTimeout(() => { + const text = REPLIES[replies.current++ % REPLIES.length]! + setThread((list) => [...list, { id: `a${list.length}`, role: "assistant", text }]) + setReplying(false) + }, REPLY_MS) + return () => window.clearTimeout(id) + }, [replying, thread]) + + const onAttachmentsChange = (next: PromptBoxAttachment[]) => { + setFiles( + next.map((file) => { + if (known.current.has(file.id)) return file + known.current.add(file.id) + return s.uploading ? { ...file, progress: 0 } : file + }) + ) + } + + const onSubmit = (message: PromptBoxMessage) => { + setThread((list) => [ + ...list, + { + id: `u${list.length}`, + role: "user", + text: message.text, + images: message.attachments.map(({ name, url }) => ({ name, url })), + }, + ]) + setText("") + setFiles([]) + setReplying(true) + } + + return ( +
+
+
+ {thread.map((message) => + message.role === "user" ? ( +
+ {message.images?.length ? ( +
+ {message.images.map((image) => ( + {image.name} + ))} +
+ ) : null} + {message.text ? ( +

+ {message.text} +

+ ) : null} +
+ ) : ( +

+ {message.text} +

+ ) + )} + {replying ? ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ) : null} +
+
+ +
+ setReplying(false)} + onSubmit={onSubmit} + minRows={Math.max(1, Math.round(s.minRows))} + toolbar={ + <> + Auto + + Opus 5.5 + + + + + + } + /> +
+
+ ) +} diff --git a/21st/shader-anime-fire.demo.tsx b/21st/shader-anime-fire.demo.tsx new file mode 100644 index 0000000..4864dee --- /dev/null +++ b/21st/shader-anime-fire.demo.tsx @@ -0,0 +1,35 @@ +import { ShaderAnimeFire } from "./shader-anime-fire" + +const settings = { + speed: 0.6, + intensity: 1, + height: 0.8, + interactive: true, + colors: ["#D8341A", "#F9731E", "#FFBA3A"], +} + +// The stock light palette. Left as is, the fire follows the site theme and +// swaps to its own dark palette; any other palette is used in both themes. +const STOCK = ["#D8341A", "#F9731E", "#FFBA3A"] + +function isStock(colors: string[]) { + return ( + colors.length === STOCK.length && + colors.every((c, i) => c.toUpperCase() === STOCK[i]) + ) +} + +export default function ShaderAnimeFireDemo(props: Partial) { + const s = { ...settings, ...props } + return ( +
+ +
+ ) +} diff --git a/21st/shader-sky.demo.tsx b/21st/shader-sky.demo.tsx new file mode 100644 index 0000000..a8cec25 --- /dev/null +++ b/21st/shader-sky.demo.tsx @@ -0,0 +1,39 @@ +import { ShaderSky } from "./shader-sky" + +const settings = { + speed: 0.1, + coverage: 0.5, + amount: 0.5, + lightning: true, + glass: false, + glassSize: 7, + colors: ["#2478C8", "#8ECBF2", "#F7FBFF", "#C5D8EC"], +} + +// The stock clear-sky palette. Left as is, the sky follows the site theme +// and turns to storm in dark mode; any other palette stays put in both. +const STOCK = ["#2478C8", "#8ECBF2", "#F7FBFF", "#C5D8EC"] + +function isStock(colors: string[]) { + return ( + colors.length === STOCK.length && + colors.every((c, i) => c.toUpperCase() === STOCK[i]) + ) +} + +export default function ShaderSkyDemo(props: Partial) { + const s = { ...settings, ...props } + return ( +
+ +
+ ) +} diff --git a/21st/snake-404.demo.tsx b/21st/snake-404.demo.tsx new file mode 100644 index 0000000..8622e26 --- /dev/null +++ b/21st/snake-404.demo.tsx @@ -0,0 +1,54 @@ +"use client" + +import { Snake404 } from "./snake-404" + +const settings = { + code: "404", + cellSize: 10, + speed: 14, + maxLength: 48, + grid: true, + lcd: false, + score: true, + playable: true, +} + +export default function Snake404Demo(props: Partial) { + const s = { ...settings, ...props } + + return ( +
+
+ +
+ +
+
+

+ This page could not be found +

+

+ {s.playable + ? "Click the board and steer with the arrow keys." + : "It may have moved, or never existed."} +

+
+ + Back home + +
+
+ ) +} diff --git a/21st/stretchy-footer.demo.tsx b/21st/stretchy-footer.demo.tsx new file mode 100644 index 0000000..f5e64f5 --- /dev/null +++ b/21st/stretchy-footer.demo.tsx @@ -0,0 +1,74 @@ +import { StretchyFooter } from "./stretchy-footer" + +const settings = { + peek: 0.7, + maxStretch: 480, + columns: 9, + blur: 16, + glow: 0.2, + flip: false, + colors: [ + "#FF1AE6", + "#FF2E5E", + "#FF8A1A", + "#FFC81F", + "#E2E6EC", + "#8DB6E6", + "#2F74DC", + "#1459E3", + "#1C2566", + ], +} + +const LINKS = [ + ["Product", "Features", "Pricing", "Changelog"], + ["Company", "About", "Careers", "Contact"], + ["Legal", "Privacy", "Terms", "Cookies"], +] + +export default function StretchyFooterDemo(props: Partial) { + const s = { ...settings, ...props } + return ( + + + + ) +} diff --git a/content/docs/components/dither-scan.mdx b/content/docs/components/dither-scan.mdx index 15ba276..9ec9550 100644 --- a/content/docs/components/dither-scan.mdx +++ b/content/docs/components/dither-scan.mdx @@ -70,28 +70,29 @@ Remote images must send CORS headers, because the dither reads their pixels. An ## Props -| Prop | Type | Default | -| ------------ | ---------------------------------------- | -------------------------- | -| `src` | `string` | required | -| `alt` | `string` | `""` | -| `passes` | `number` | `4`, 1–12 | -| `duration` | `number` | `1400`, ms per pass | -| `pause` | `number` | `280`, ms between passes | -| `angle` | `number` | `45`, degrees of travel | -| `alternate` | `boolean` | `true` | -| `pixelSize` | `number` | `6`, first-pass cell in px | -| `blur` | `number` | `6`, px, `0` is off | -| `colors` | `[shadow: string, highlight: string]` | theme ink and paper | -| `lineColor` | `string` | inverts what's under it | -| `fit` | `"cover" \| "contain"` | `"cover"` | -| `autoPlay` | `boolean` | `true`, when in view | -| `loop` | `boolean` | `false` | -| `loopDelay` | `number` | `2400`, ms on clean image | -| `replayKey` | `number` | `0` | -| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | -| `onStart` | `() => void` | none | -| `onPass` | `(pass: number, passes: number) => void` | none | -| `onComplete` | `() => void` | none | -| `className` | `string` | none | - -With reduced motion the image is shown clean, without sweeps. +| Prop | Type | Default | +| ----------------------- | ---------------------------------------- | -------------------------- | +| `src` | `string` | required | +| `alt` | `string` | `""` | +| `passes` | `number` | `4`, 1–12 | +| `duration` | `number` | `1400`, ms per pass | +| `pause` | `number` | `280`, ms between passes | +| `angle` | `number` | `45`, degrees of travel | +| `alternate` | `boolean` | `true` | +| `pixelSize` | `number` | `6`, first-pass cell in px | +| `blur` | `number` | `6`, px, `0` is off | +| `colors` | `[shadow: string, highlight: string]` | theme ink and paper | +| `lineColor` | `string` | inverts what's under it | +| `fit` | `"cover" \| "contain"` | `"cover"` | +| `autoPlay` | `boolean` | `true`, when in view | +| `loop` | `boolean` | `false` | +| `loopDelay` | `number` | `2400`, ms on clean image | +| `reducedMotionProgress` | `number` | none, 0–1 | +| `replayKey` | `number` | `0` | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | +| `onStart` | `() => void` | none | +| `onPass` | `(pass: number, passes: number) => void` | none | +| `onComplete` | `() => void` | none | +| `className` | `string` | none | + +With reduced motion the image is shown clean, without sweeps. Set `reducedMotionProgress` (0–1) to hold one still frame instead, with the line resting that far across: the 1-bit print ahead of it, the clean image behind. diff --git a/content/docs/components/mech-keyboard.mdx b/content/docs/components/mech-keyboard.mdx index 3926250..447b102 100644 --- a/content/docs/components/mech-keyboard.mdx +++ b/content/docs/components/mech-keyboard.mdx @@ -62,6 +62,7 @@ Needs WebGL; without it the canvas stays empty and the recorder still works. The | `interactive` | `boolean` | `true` | | `recorder` | `boolean` | `true` | | `placeholder` | `string` | `"Press a shortcut"` | +| `defaultShortcut` | `string[]` | none, codes shown as saved | | `highlight` | `string[]` | none, codes to pulse | | `platform` | `"auto" \| "mac" \| "pc"` | `"auto"`, bottom-row legends | | `sound` | `boolean` | `false` | diff --git a/content/docs/components/stretchy-footer.mdx b/content/docs/components/stretchy-footer.mdx index 1bb644b..cc231e8 100644 --- a/content/docs/components/stretchy-footer.mdx +++ b/content/docs/components/stretchy-footer.mdx @@ -72,6 +72,7 @@ export function DocsChrome() { | `glow` | `number` | `0`, floor bloom, 0–1 | | `flip` | `boolean` | `false`, valley shape | | `rotate` | `0 \| 180` | `0`, `180` hangs it from the page | +| `peek` | `number` | `0`, resting stretch, 0–1 of `maxStretch` | | `label` | `string` | `Stretchy overflow` | | `demoId` | `string` | pairs with `playStretchyFooterDemo({ target })` | | `className` | `string` | none | diff --git a/eslint.config.mjs b/eslint.config.mjs index 6d8ef5d..449e680 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,14 @@ const eslintConfig = defineConfig([ ".open-next/**", ".wrangler/**", ]), + // 21st/ demos run standalone in 21st.dev's sandbox, not as Next pages. + { + files: ["21st/**"], + rules: { + "@next/next/no-html-link-for-pages": "off", + "@next/next/no-img-element": "off", + }, + }, ]); export default eslintConfig; diff --git a/public/r/dither-scan.json b/public/r/dither-scan.json index bbe2668..2d8ff94 100644 --- a/public/r/dither-scan.json +++ b/public/r/dither-scan.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/dither-scan/dither-scan.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState, type CSSProperties } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type DitherScanTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type DitherScanFit = \"cover\" | \"contain\"\n\nexport type DitherScanOptions = {\n /** Image URL. Remote images need CORS (`Access-Control-Allow-Origin`). */\n src: string\n /** Sweeps it takes to clear the dither. Default `4`, clamped to 1–12 */\n passes?: number\n /** Milliseconds per sweep. Default `1400` */\n duration?: number\n /** Milliseconds between sweeps. Default `280` */\n pause?: number\n /** Direction the line travels, in degrees (0 = left to right). Default `45` */\n angle?: number\n /** Every other sweep runs back the way it came. Default `true` */\n alternate?: boolean\n /** Dither cell size of the first pass, in CSS px. Default `6` */\n pixelSize?: number\n /** `[shadow, highlight]` for the 1-bit print. Default follows theme. */\n colors?: readonly [string, string]\n /** Blur in CSS px that follows the line while it settles. `0` turns it off. Default `6` */\n blur?: number\n /** Scan line color. Default inverts whatever is under it. */\n lineColor?: string\n /** How the image fills the frame. Default `cover` */\n fit?: DitherScanFit\n /** Start once the frame scrolls into view. Default `true` */\n autoPlay?: boolean\n /** Re-dither and scan again after a hold. Default `false` */\n loop?: boolean\n /** Hold on the clean image before a loop, in ms. Default `2400` */\n loopDelay?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: DitherScanTheme\n /** Fires with the image's natural size once it loads. */\n onLoad?: (width: number, height: number) => 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", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState, type CSSProperties } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type DitherScanTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type DitherScanFit = \"cover\" | \"contain\"\n\nexport type DitherScanOptions = {\n /** Image URL. Remote images need CORS (`Access-Control-Allow-Origin`). */\n src: string\n /** Sweeps it takes to clear the dither. Default `4`, clamped to 1–12 */\n passes?: number\n /** Milliseconds per sweep. Default `1400` */\n duration?: number\n /** Milliseconds between sweeps. Default `280` */\n pause?: number\n /** Direction the line travels, in degrees (0 = left to right). Default `45` */\n angle?: number\n /** Every other sweep runs back the way it came. Default `true` */\n alternate?: boolean\n /** Dither cell size of the first pass, in CSS px. Default `6` */\n pixelSize?: number\n /** `[shadow, highlight]` for the 1-bit print. Default follows theme. */\n colors?: readonly [string, string]\n /** Blur in CSS px that follows the line while it settles. `0` turns it off. Default `6` */\n blur?: number\n /** Scan line color. Default inverts whatever is under it. */\n lineColor?: string\n /** How the image fills the frame. Default `cover` */\n fit?: DitherScanFit\n /** Start once the frame scrolls into view. Default `true` */\n autoPlay?: boolean\n /** Re-dither and scan again after a hold. Default `false` */\n loop?: boolean\n /** Hold on the clean image before a loop, in ms. Default `2400` */\n loopDelay?: number\n /**\n * Under `prefers-reduced-motion`, hold one frame with the line this far\n * across, 0–1: the 1-bit print ahead of it, the clean image behind.\n * Unset shows the clean image.\n */\n reducedMotionProgress?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: DitherScanTheme\n /** Fires with the image's natural size once it loads. */\n onLoad?: (width: number, height: number) => 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/** Reduced motion: one frame, the line resting part way across. */\nconst STILL = 5\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 if (state === STILL) {\n drawStill()\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 /** The 1-bit print with the clean image up to the resting line. */\n const drawStill = () => {\n const at = Math.min(1, Math.max(0, options.reducedMotionProgress ?? 0))\n const a = ((options.angle ?? 45) * Math.PI) / 180\n const cos = Math.abs(Math.cos(a))\n const sin = Math.abs(Math.sin(a))\n const e = (cos * cssW + sin * cssH) / 2\n const f = (sin * cssW + cos * cssH) / 2 + 2\n const u = -e + 2 * e * at\n\n drawLevel(0)\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(levels.length - 1)\n ctx.restore()\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 (reduce && last > 0 && options.reducedMotionProgress != null) {\n pass = 0\n state = STILL\n setScanning(false)\n redraw()\n return\n }\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 (\n reduce &&\n prev.reducedMotionProgress !== options.reducedMotionProgress &&\n (state === DONE || state === STILL)\n ) {\n begin()\n return\n }\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 reducedMotionProgress,\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 reducedMotionProgress,\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 reducedMotionProgress,\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" } diff --git a/public/r/mech-keyboard.json b/public/r/mech-keyboard.json index faffb73..7b2e9a4 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. */\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 /**\n * `KeyboardEvent.code`s the recorder starts on, shown as saved until the\n * next key, e.g. the current binding: `[\"MetaLeft\", \"KeyK\"]`.\n */\n defaultShortcut?: 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 defaultShortcut,\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 }>(() =>\n defaultShortcut?.length\n ? { codes: defaultShortcut, phase: \"done\" }\n : { codes: [], phase: \"idle\" }\n )\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/snake-404.json b/public/r/snake-404.json index 5e707ce..352d9c3 100644 --- a/public/r/snake-404.json +++ b/public/r/snake-404.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/snake-404/snake-404.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Snake404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Snake404Options = {\n /** Digits drawn as food. Default `\"404\"`. Anything but 0–9 is dropped. */\n code?: string\n /** Snake and food ink (hex). Default follows theme, or LCD ink. */\n color?: string\n /** Grid cell in CSS px. Default `12` */\n cellSize?: number\n /** Cells per second. Default `14` */\n speed?: number\n /** Longest the snake grows. Default `32` */\n maxLength?: number\n /** Faint unlit cells behind the field. Default `true` */\n grid?: boolean\n /** Paint a green Nokia LCD behind the field. Default `false` */\n lcd?: boolean\n /** Arrow keys, WASD, or swipes take over the snake. Default `true` */\n playable?: boolean\n /** Pellets eaten, top left. Default `true` */\n score?: boolean\n /** Glide between cells. Off steps cell by cell. Default `true` */\n smooth?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Snake404Theme\n /** After each pellet: how many are left, out of how many. */\n onEat?: (left: number, total: number) => void\n /** The last pellet is gone; the digits come back shortly after. */\n onClear?: () => void\n /** A player took over (`true`) or handed back to autopilot (`false`). */\n onPlayChange?: (playing: boolean) => void\n}\n\n/** 0 right, 1 down, 2 left, 3 up */\nexport type Snake404Direction = 0 | 1 | 2 | 3\n\nexport type Snake404Instance = {\n setOptions: (options: Partial) => void\n /** Steer as the player. Takes over from autopilot. */\n steer: (direction: Snake404Direction) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n/** Nokia 3310 screen */\nexport const LCD_SCREEN = \"#C7F0D8\"\n/** Nokia 3310 pixels */\nexport const LCD_INK = \"#43523D\"\n\nconst START_LENGTH = 4\nconst ENTER_DELAY_MS = 700\nconst REFILL_DELAY_MS = 900\nconst REFILL_MS = 700\nconst POP_MS = 240\nconst DEATH_MS = 1300\nconst BLINK_MS = 150\nconst BLINKS = 6\nconst MAX_QUEUE = 3\nconst SWIPE_PX = 24\n\nconst DX = [1, 0, -1, 0] as const\nconst DY = [0, 1, 0, -1] as const\n\n/** 6×7 bold digits, `#` is food. */\nconst DIGITS: Record = {\n \"0\": [\".####.\", \"##..##\", \"##..##\", \"##..##\", \"##..##\", \"##..##\", \".####.\"],\n \"1\": [\"..##..\", \".###..\", \"..##..\", \"..##..\", \"..##..\", \"..##..\", \".####.\"],\n \"2\": [\".####.\", \"##..##\", \"....##\", \"..###.\", \".##...\", \"##....\", \"######\"],\n \"3\": [\".####.\", \"##..##\", \"....##\", \"..###.\", \"....##\", \"##..##\", \".####.\"],\n \"4\": [\"##..##\", \"##..##\", \"##..##\", \"######\", \"....##\", \"....##\", \"....##\"],\n \"5\": [\"######\", \"##....\", \"#####.\", \"....##\", \"....##\", \"##..##\", \".####.\"],\n \"6\": [\".####.\", \"##....\", \"#####.\", \"##..##\", \"##..##\", \"##..##\", \".####.\"],\n \"7\": [\"######\", \"....##\", \"...##.\", \"..##..\", \"..##..\", \"..##..\", \"..##..\"],\n \"8\": [\".####.\", \"##..##\", \"##..##\", \".####.\", \"##..##\", \"##..##\", \".####.\"],\n \"9\": [\".####.\", \"##..##\", \"##..##\", \".#####\", \"....##\", \"....##\", \".####.\"],\n}\nconst DIGIT_W = 6\nconst DIGIT_H = 7\nconst DIGIT_GAP = 2\n\n/** 3×5 score digits, one row per string. */\nconst SCORE_DIGITS = [\n \"111101101101111\",\n \"010110010010111\",\n \"111001111100111\",\n \"111001111001111\",\n \"101101111001001\",\n \"111100111001111\",\n \"111100111101111\",\n \"111001001001001\",\n \"111101111101111\",\n \"111101111001111\",\n] as const\n\nconst BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5] 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: Snake404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\n/** Digits of `code` that have a glyph, `\"404\"` when none do. */\nexport function codeDigits(code: string | undefined): string {\n const digits = (code ?? \"\").replace(/[^0-9]/g, \"\").slice(0, 6)\n return digits || \"404\"\n}\n\nfunction easeOutBack(t: number) {\n const c = 1.70158\n const u = t - 1\n return 1 + (c + 1) * u * u * u + c * u * u\n}\n\nconst KEYS: Record = {\n ArrowRight: 0,\n ArrowDown: 1,\n ArrowLeft: 2,\n ArrowUp: 3,\n d: 0,\n s: 1,\n a: 2,\n w: 3,\n D: 0,\n S: 1,\n A: 2,\n W: 3,\n}\n\ntype Phase = \"wait\" | \"run\" | \"dead\"\n\nexport function createSnake404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Snake404Options = {}\n): Snake404Instance | null {\n let options: Snake404Options = {\n code: \"404\",\n cellSize: 12,\n speed: 14,\n maxLength: 32,\n grid: true,\n lcd: false,\n playable: true,\n score: true,\n smooth: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let visible = true\n let running = true\n let raf = 0\n let lastNow = performance.now()\n\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cell = 12\n let cols = 0\n let rows = 0\n let ox = 0\n let oy = 0\n let builtKey = \"\"\n\n // Field\n let food = new Uint8Array(0)\n let born = new Float64Array(0)\n let glyph: number[] = []\n let remaining = 0\n let refillAt = 0\n\n // Snake, head first. Cells may sit off the grid while it slides in.\n let bx: number[] = []\n let by: number[] = []\n let fed: boolean[] = []\n let occ = new Uint8Array(0)\n let dir: Snake404Direction = 0\n let nextX = 0\n let nextY = 0\n let growing = false\n let phase: Phase = \"wait\"\n let enterAt = 0\n let deadAt = 0\n let acc = 0\n let score = 0\n\n let playing = false\n let queue: Snake404Direction[] = []\n\n // Search scratch, reused every step.\n let seen = new Uint32Array(0)\n let dist = new Int32Array(0)\n let bfs = new Int32Array(0)\n let stamp = 0\n\n // Unlit LCD cells, drawn once per layout and ink.\n const ghost = document.createElement(\"canvas\")\n let ghostKey = \"\"\n\n const inkOf = () =>\n options.color ?? (options.lcd ? LCD_INK : dark ? DARK_COLOR : LIGHT_COLOR)\n\n const inGrid = (x: number, y: number) =>\n x >= 0 && y >= 0 && x < cols && y < rows\n\n const tailCell = () => {\n const i = bx.length - 1\n return inGrid(bx[i]!, by[i]!) ? by[i]! * cols + bx[i]! : -1\n }\n\n /** Free next step: empty, or the tail cell that moves out this step. */\n const passable = (x: number, y: number, freeTail: number) => {\n if (!inGrid(x, y)) return false\n const i = y * cols + x\n return occ[i] === 0 || (i === freeTail && occ[i] === 1)\n }\n\n const nextStamp = () => {\n stamp++\n if (stamp === 0xffffffff) {\n seen.fill(0)\n stamp = 1\n }\n return stamp\n }\n\n /** Cells reachable from `start`, stopping once it passes `cap`. */\n const flood = (start: number, freeTail: number, cap: number) => {\n const s = nextStamp()\n // The head moves to `start`, so the old head cell stays blocked.\n seen[start] = s\n bfs[0] = start\n let head = 0\n let tail = 1\n while (head < tail) {\n const c = bfs[head++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (let d = 0; d < 4; d++) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n bfs[tail++] = n\n if (tail > cap) return tail\n }\n }\n return tail\n }\n\n const visibleFood = (i: number, now: number) => food[i] === 1 && born[i]! <= now\n\n /** Autopilot: shortest way to the nearest pellet that will not trap it. */\n const autopilot = (freeTail: number, now: number): Snake404Direction => {\n const hx = bx[0]!\n const hy = by[0]!\n if (!inGrid(hx, hy)) return dir\n const head = hy * cols + hx\n const order = [dir, (dir + 3) % 4, (dir + 1) % 4] as Snake404Direction[]\n const len = bx.length\n\n // Nearest pellet by path length.\n let target = -1\n if (remaining > 0) {\n const s = nextStamp()\n seen[head] = s\n bfs[0] = head\n let qh = 0\n let qt = 1\n search: while (qh < qt) {\n const c = bfs[qh++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (const d of order) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n if (visibleFood(n, now)) {\n target = n\n break search\n }\n bfs[qt++] = n\n }\n }\n }\n\n let pick = -1\n if (target >= 0) {\n // Distances back from the pellet; step to the neighbor closest to it,\n // keeping straight on ties so paths turn once instead of zigzagging.\n const s = nextStamp()\n seen[target] = s\n dist[target] = 0\n bfs[0] = target\n let qh = 0\n let qt = 1\n while (qh < qt) {\n const c = bfs[qh++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (let d = 0; d < 4; d++) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n dist[n] = dist[c]! + 1\n bfs[qt++] = n\n }\n }\n let best = Infinity\n for (const d of order) {\n const nx = hx + DX[d]!\n const ny = hy + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] !== s) continue\n if (dist[n]! < best) {\n best = dist[n]!\n pick = d\n }\n }\n if (pick >= 0) {\n const n = (hy + DY[pick]!) * cols + hx + DX[pick]!\n if (flood(n, freeTail, len) < len) pick = -1\n }\n }\n if (pick >= 0) return pick as Snake404Direction\n\n // No safe pellet: wander, mostly straight, away from walls and itself.\n const cap = len * 2 + 8\n let bestArea = -1\n let bestDir = dir\n const ahead = (d: number) =>\n passable(hx + DX[d]! * 2, hy + DY[d]! * 2, freeTail) ? 1 : 0\n const wander =\n Math.random() < 0.08 ? [order[1]!, order[2]!, order[0]!] : order\n for (const d of wander) {\n const nx = hx + DX[d]!\n const ny = hy + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const area = flood(ny * cols + nx, freeTail, cap) * 2 + ahead(d)\n if (area > bestArea) {\n bestArea = area\n bestDir = d\n }\n }\n return bestDir\n }\n\n const die = (now: number) => {\n phase = \"dead\"\n deadAt = now\n queue = []\n if (playing) {\n playing = false\n options.onPlayChange?.(false)\n }\n }\n\n /** Decide the move for the step now starting. */\n const plan = (now: number) => {\n const last = bx.length - 1\n growing = fed[last]! && bx.length < Math.max(START_LENGTH, options.maxLength ?? 32)\n const freeTail = growing ? -1 : tailCell()\n let d = dir\n if (playing) {\n if (queue.length) d = queue.shift()!\n } else {\n d = autopilot(freeTail, now)\n }\n const nx = bx[0]! + DX[d]!\n const ny = by[0]! + DY[d]!\n if (!passable(nx, ny, freeTail)) {\n die(now)\n return\n }\n dir = d\n nextX = nx\n nextY = ny\n }\n\n /** Finish the step: the tail follows, the head lands and maybe eats. */\n const commit = (now: number) => {\n const last = bx.length - 1\n if (growing) {\n // The bulge reached the tail; the tail holds and the snake is longer.\n fed[last] = false\n } else {\n if (inGrid(bx[last]!, by[last]!)) {\n const t = by[last]! * cols + bx[last]!\n occ[t] = occ[t]! - 1\n }\n bx.pop()\n by.pop()\n fed.pop()\n }\n bx.unshift(nextX)\n by.unshift(nextY)\n const i = nextY * cols + nextX\n occ[i] = occ[i]! + 1\n const ate = food[i] === 1\n fed.unshift(ate)\n if (ate) {\n food[i] = 0\n remaining--\n score++\n options.onEat?.(remaining, glyph.length)\n if (remaining === 0) {\n refillAt = now + REFILL_DELAY_MS\n options.onClear?.()\n }\n }\n }\n\n const spawn = (now: number) => {\n const row = Math.floor(rows / 2)\n // Slide in from the left edge; reduced motion parks it on screen.\n const onScreen = reduce\n bx = []\n by = []\n fed = []\n occ.fill(0)\n for (let k = 0; k < START_LENGTH; k++) {\n const x = onScreen ? START_LENGTH - k : -1 - k\n bx.push(x)\n by.push(row)\n fed.push(false)\n if (inGrid(x, row)) occ[row * cols + x] = 1\n }\n dir = 0\n nextX = bx[0]! + 1\n nextY = row\n growing = false\n acc = 0\n score = 0\n queue = []\n phase = onScreen ? \"run\" : \"wait\"\n enterAt = now + ENTER_DELAY_MS\n }\n\n const refill = (now: number) => {\n const span = Math.max(1, cols + rows)\n for (const i of glyph) {\n if (food[i] === 1) continue\n const x = i % cols\n const y = (i / cols) | 0\n food[i] = 1\n born[i] =\n now + ((x + y) / span) * REFILL_MS * 2 + (BAYER4[(y & 3) * 4 + (x & 3)]! / 16) * 140\n remaining++\n }\n }\n\n const layout = (now: number) => {\n cell = Math.max(4, Math.round(options.cellSize ?? 12))\n cols = Math.max(1, Math.floor(cssW / cell))\n rows = Math.max(1, Math.floor(cssH / cell))\n ox = Math.floor((cssW - cols * cell) / 2)\n oy = Math.floor((cssH - rows * cell) / 2)\n const n = cols * rows\n food = new Uint8Array(n)\n born = new Float64Array(n)\n occ = new Uint8Array(n)\n seen = new Uint32Array(n)\n dist = new Int32Array(n)\n bfs = new Int32Array(n + 4)\n stamp = 0\n glyph = []\n remaining = 0\n refillAt = 0\n\n const digits = codeDigits(options.code)\n const bw = digits.length * DIGIT_W + (digits.length - 1) * DIGIT_GAP\n const k = Math.max(\n 1,\n Math.floor(Math.min((cols * 0.74) / bw, (rows * 0.5) / DIGIT_H))\n )\n const gx0 = Math.floor((cols - bw * k) / 2)\n const gy0 = Math.floor((rows - DIGIT_H * k) / 2)\n for (let di = 0; di < digits.length; di++) {\n const bitmap = DIGITS[digits[di]!]!\n const left = gx0 + di * (DIGIT_W + DIGIT_GAP) * k\n for (let r = 0; r < DIGIT_H; r++) {\n for (let c = 0; c < DIGIT_W; c++) {\n if (bitmap[r]![c] !== \"#\") continue\n for (let sy = 0; sy < k; sy++) {\n for (let sx = 0; sx < k; sx++) {\n const x = left + c * k + sx\n const y = gy0 + r * k + sy\n if (!inGrid(x, y)) continue\n const i = y * cols + x\n food[i] = 1\n glyph.push(i)\n remaining++\n }\n }\n }\n }\n }\n if (playing) {\n playing = false\n options.onPlayChange?.(false)\n }\n spawn(now)\n }\n\n /** Returns whether the backing store changed (which clears the canvas). */\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return false\n cssW = w\n cssH = h\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n const changed = width !== canvas.width || height !== canvas.height\n if (changed) {\n canvas.width = width\n canvas.height = height\n }\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n const c = Math.max(4, Math.round(options.cellSize ?? 12))\n const key = `${Math.floor(w / c)}x${Math.floor(h / c)}:${c}:${codeDigits(options.code)}`\n if (key !== builtKey) {\n builtKey = key\n layout(performance.now())\n }\n return changed\n }\n\n const drawGhost = (ink: string) => {\n const key = `${canvas.width}x${canvas.height}:${cols}x${rows}:${cell}:${ink}:${options.lcd}:${dark}`\n if (key === ghostKey) return\n ghostKey = key\n ghost.width = canvas.width\n ghost.height = canvas.height\n const g = ghost.getContext(\"2d\")\n if (!g) return\n g.setTransform(dpr, 0, 0, dpr, 0, 0)\n g.clearRect(0, 0, cssW, cssH)\n g.fillStyle = ink\n g.globalAlpha = options.lcd ? 0.07 : dark ? 0.05 : 0.035\n const inset = Math.max(1, Math.round(cell * 0.16))\n const size = cell - inset * 2\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n g.fillRect(ox + x * cell + inset, oy + y * cell + inset, size, size)\n }\n }\n }\n\n const draw = (now: number, p: number) => {\n const ink = inkOf()\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.clearRect(0, 0, cssW, cssH)\n if (options.lcd) {\n ctx.fillStyle = LCD_SCREEN\n ctx.fillRect(0, 0, cssW, cssH)\n }\n if (options.grid) {\n drawGhost(ink)\n ctx.drawImage(ghost, 0, 0, cssW, cssH)\n }\n\n ctx.fillStyle = ink\n\n // Food: one pellet per lit cell, popping in after a refill.\n const inset = Math.max(1, Math.round(cell * 0.16))\n const pellet = cell - inset * 2\n for (const i of glyph) {\n if (food[i] !== 1) continue\n const t = (now - born[i]!) / POP_MS\n if (t < 0) continue\n const x = ox + (i % cols) * cell\n const y = oy + ((i / cols) | 0) * cell\n if (t >= 1) {\n ctx.fillRect(x + inset, y + inset, pellet, pellet)\n } else {\n const s = pellet * easeOutBack(t)\n const o = (cell - s) / 2\n ctx.fillRect(x + o, y + o, s, s)\n }\n }\n\n // Snake.\n const dead = phase === \"dead\"\n const blink = Math.floor((now - deadAt) / BLINK_MS)\n const shown = !dead || (blink < BLINKS && blink % 2 === 1)\n if (shown && bx.length > 0) {\n // Body as thick as a pellet; a meal swells it to nearly a full cell.\n const th = pellet\n const half = th / 2\n const cx = (x: number) => ox + (x + 0.5) * cell\n const cy = (y: number) => oy + (y + 0.5) * cell\n const moving = phase === \"run\"\n const t = moving ? p : 0\n const last = bx.length - 1\n\n const hx = cx(bx[0]! + (moving ? (nextX - bx[0]!) * t : 0))\n const hy = cy(by[0]! + (moving ? (nextY - by[0]!) * t : 0))\n const tailT = moving && !growing ? t : 0\n const tx = cx(bx[last]! + (bx[Math.max(0, last - 1)]! - bx[last]!) * tailT)\n const ty = cy(by[last]! + (by[Math.max(0, last - 1)]! - by[last]!) * tailT)\n\n const band = (ax: number, ay: number, bx2: number, by2: number) => {\n ctx.fillRect(\n Math.min(ax, bx2) - half,\n Math.min(ay, by2) - half,\n Math.abs(ax - bx2) + th,\n Math.abs(ay - by2) + th\n )\n }\n\n let px = hx\n let py = hy\n for (let k = 0; k < last; k++) {\n const qx = cx(bx[k]!)\n const qy = cy(by[k]!)\n band(px, py, qx, qy)\n px = qx\n py = qy\n }\n band(px, py, tx, ty)\n\n // Swallowed pellets: a bulge that stays put while the body slides by.\n const swell = cell - Math.max(1, Math.round(cell * 0.06)) * 2\n for (let k = 1; k <= last; k++) {\n if (!fed[k] || (k === last && !growing)) continue\n ctx.fillRect(cx(bx[k]!) - swell / 2, cy(by[k]!) - swell / 2, swell, swell)\n }\n\n // Eye, and a mouth that opens when food is next.\n const fx = DX[dir]!\n const fy = DY[dir]!\n const lx = fy\n const ly = -fx\n const cut = (x: number, y: number, w: number, h: number) => {\n if (options.lcd) {\n ctx.fillStyle = LCD_SCREEN\n ctx.fillRect(x, y, w, h)\n ctx.fillStyle = ink\n return\n }\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillRect(x, y, w, h)\n ctx.globalCompositeOperation = \"source-over\"\n }\n // The head is a full swell, so the eye and mouth have room.\n ctx.fillRect(hx - swell / 2, hy - swell / 2, swell, swell)\n const eye = Math.max(2, Math.round(cell * 0.2))\n const ex = hx - fx * swell * 0.06 + lx * swell * 0.22\n const ey = hy - fy * swell * 0.06 + ly * swell * 0.22\n cut(ex - eye / 2, ey - eye / 2, eye, eye)\n if (moving && inGrid(nextX, nextY) && visibleFood(nextY * cols + nextX, now)) {\n const w = Math.max(2, Math.round(swell * 0.36))\n const depth = Math.max(1, Math.round(swell * 0.3))\n const mx = hx + fx * (swell / 2 - depth / 2)\n const my = hy + fy * (swell / 2 - depth / 2)\n const mw = fx !== 0 ? depth : w\n const mh = fx !== 0 ? w : depth\n cut(mx - mw / 2, my - mh / 2, mw, mh)\n }\n }\n\n if (options.score) {\n const dot = Math.max(2, Math.round(cell / 4))\n const digits = String(score).padStart(4, \"0\")\n const x0 = ox + cell\n const y0 = oy + cell\n ctx.globalAlpha = 0.6\n for (let d = 0; d < digits.length; d++) {\n const bits = SCORE_DIGITS[digits.charCodeAt(d) - 48]!\n for (let b = 0; b < 15; b++) {\n if (bits[b] !== \"1\") continue\n ctx.fillRect(\n x0 + d * dot * 4 + (b % 3) * dot,\n y0 + Math.floor(b / 3) * dot,\n dot,\n dot\n )\n }\n }\n ctx.globalAlpha = 1\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(100, now - lastNow)\n lastNow = now\n\n if (refillAt && now >= refillAt) {\n refillAt = 0\n refill(now)\n }\n if (phase === \"wait\" && now >= enterAt && !reduce) phase = \"run\"\n if (phase === \"dead\" && now - deadAt >= DEATH_MS) spawn(now)\n\n const stepMs = 1000 / Math.max(1, options.speed ?? 14)\n if (phase === \"run\" && (!reduce || playing)) {\n acc += dt\n while (acc >= stepMs && phase === \"run\") {\n acc -= stepMs\n commit(now)\n plan(now)\n }\n }\n const p = options.smooth ? Math.min(1, acc / stepMs) : 0\n draw(now, p)\n\n if (visible) raf = requestAnimationFrame(tick)\n }\n\n const start = () => {\n cancelAnimationFrame(raf)\n lastNow = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const steer = (d: Snake404Direction) => {\n if (!options.playable || phase === \"dead\") return\n if (!playing) {\n playing = true\n queue = []\n options.onPlayChange?.(true)\n }\n if (phase === \"wait\") phase = \"run\"\n const last = queue.length ? queue[queue.length - 1]! : dir\n if (d === last || d === (last + 2) % 4) return\n if (queue.length < MAX_QUEUE) queue.push(d)\n }\n\n const handBack = () => {\n if (!playing) return\n playing = false\n queue = []\n options.onPlayChange?.(false)\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n reduce = mqReduce.matches\n const onReduce = () => {\n const was = reduce\n reduce = mqReduce.matches\n if (reduce !== was && !playing) spawn(performance.now())\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n resize()\n draw(performance.now(), 0)\n\n const ro = new ResizeObserver(() => {\n const changed = resize()\n if (changed && running) draw(performance.now(), 0)\n })\n ro.observe(wrap)\n\n const io = new IntersectionObserver(([entry]) => {\n const was = visible\n visible = entry?.isIntersecting ?? true\n if (visible && !was) start()\n })\n io.observe(wrap)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onKey = (e: KeyboardEvent) => {\n if (!options.playable || e.metaKey || e.ctrlKey || e.altKey) return\n if (e.key === \"Escape\") {\n if (playing) {\n e.preventDefault()\n handBack()\n }\n return\n }\n const d = KEYS[e.key]\n if (d === undefined) return\n e.preventDefault()\n steer(d)\n }\n\n let swipe: { id: number; x: number; y: number } | null = null\n const onDown = (e: PointerEvent) => {\n if (!options.playable) return\n swipe = { id: e.pointerId, x: e.clientX, y: e.clientY }\n }\n const onUp = (e: PointerEvent) => {\n if (!swipe || swipe.id !== e.pointerId) return\n const dx = e.clientX - swipe.x\n const dy = e.clientY - swipe.y\n swipe = null\n if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return\n if (Math.abs(dx) > Math.abs(dy)) steer(dx > 0 ? 0 : 2)\n else steer(dy > 0 ? 1 : 3)\n }\n const onCancel = () => {\n swipe = null\n }\n\n wrap.addEventListener(\"keydown\", onKey)\n wrap.addEventListener(\"focusout\", handBack)\n wrap.addEventListener(\"pointerdown\", onDown)\n wrap.addEventListener(\"pointerup\", onUp)\n wrap.addEventListener(\"pointercancel\", onCancel)\n\n start()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (\n options.cellSize !== prev.cellSize ||\n codeDigits(options.code) !== codeDigits(prev.code)\n ) {\n builtKey = \"\"\n resize()\n }\n if (options.theme !== prev.theme) syncTheme()\n if (!options.playable) handBack()\n if (!visible) draw(performance.now(), 0)\n },\n steer,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"keydown\", onKey)\n wrap.removeEventListener(\"focusout\", handBack)\n wrap.removeEventListener(\"pointerdown\", onDown)\n wrap.removeEventListener(\"pointerup\", onUp)\n wrap.removeEventListener(\"pointercancel\", onCancel)\n },\n }\n}\n\nexport type Snake404Props = Snake404Options & {\n /** Accessible name. Default `\", page not found\"`. */\n label?: string\n className?: string\n}\n\n/**\n * A dot-matrix 404 where every dot is food. A Nokia-style snake eats its\n * way through the digits, then they come back. Click and use the arrow\n * keys, WASD, or swipe to play.\n */\nexport function Snake404({\n code = \"404\",\n color,\n cellSize = 12,\n speed = 14,\n maxLength = 32,\n grid = true,\n lcd = false,\n playable = true,\n score = true,\n smooth = true,\n theme = \"auto\",\n onEat,\n onClear,\n onPlayChange,\n label,\n className,\n}: Snake404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacksRef = useRef({ onEat, onClear, onPlayChange })\n const [playing, setPlaying] = useState(false)\n\n useEffect(() => {\n callbacksRef.current = { onEat, onClear, onPlayChange }\n }, [onEat, onClear, onPlayChange])\n\n const options = {\n code,\n color,\n cellSize,\n speed,\n maxLength,\n grid,\n lcd,\n playable,\n score,\n smooth,\n theme,\n }\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createSnake404(wrap, canvas, {\n ...options,\n onEat: (left, total) => callbacksRef.current.onEat?.(left, total),\n onClear: () => callbacksRef.current.onClear?.(),\n onPlayChange: (next) => {\n setPlaying(next)\n callbacksRef.current.onPlayChange?.(next)\n },\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 code,\n color,\n cellSize,\n speed,\n maxLength,\n grid,\n lcd,\n playable,\n score,\n smooth,\n theme,\n ])\n\n const name = label ?? `${codeDigits(code)}, page not found`\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Snake404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Snake404Options = {\n /** Digits drawn as food. Default `\"404\"`. Anything but 0–9 is dropped. */\n code?: string\n /** Snake and food ink (hex). Default follows theme, or LCD ink. */\n color?: string\n /** Grid cell in CSS px. Default `12` */\n cellSize?: number\n /** Cells per second. Default `14` */\n speed?: number\n /** Longest the snake grows. Default `32` */\n maxLength?: number\n /** Faint unlit cells behind the field. Default `true` */\n grid?: boolean\n /** Paint a green Nokia LCD behind the field. Default `false` */\n lcd?: boolean\n /** Arrow keys, WASD, or swipes take over the snake. Default `true` */\n playable?: boolean\n /** Pellets eaten, top left. Default `true` */\n score?: boolean\n /** Glide between cells. Off steps cell by cell. Default `true` */\n smooth?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Snake404Theme\n /** After each pellet: how many are left, out of how many. */\n onEat?: (left: number, total: number) => void\n /** The last pellet is gone; the digits come back shortly after. */\n onClear?: () => void\n /** A player took over (`true`) or handed back to autopilot (`false`). */\n onPlayChange?: (playing: boolean) => void\n}\n\n/** 0 right, 1 down, 2 left, 3 up */\nexport type Snake404Direction = 0 | 1 | 2 | 3\n\nexport type Snake404Instance = {\n setOptions: (options: Partial) => void\n /** Steer as the player. Takes over from autopilot. */\n steer: (direction: Snake404Direction) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n/** Nokia 3310 screen */\nexport const LCD_SCREEN = \"#C7F0D8\"\n/** Nokia 3310 pixels */\nexport const LCD_INK = \"#43523D\"\n\nconst START_LENGTH = 4\nconst ENTER_DELAY_MS = 700\nconst REFILL_DELAY_MS = 900\nconst REFILL_MS = 700\nconst POP_MS = 240\nconst DEATH_MS = 1300\nconst BLINK_MS = 150\nconst BLINKS = 6\nconst MAX_QUEUE = 3\nconst SWIPE_PX = 24\n/** Share of the digits already eaten when reduced motion holds the frame. */\nconst STILL_EATEN = 0.12\n\nconst DX = [1, 0, -1, 0] as const\nconst DY = [0, 1, 0, -1] as const\n\n/** 6×7 bold digits, `#` is food. */\nconst DIGITS: Record = {\n \"0\": [\".####.\", \"##..##\", \"##..##\", \"##..##\", \"##..##\", \"##..##\", \".####.\"],\n \"1\": [\"..##..\", \".###..\", \"..##..\", \"..##..\", \"..##..\", \"..##..\", \".####.\"],\n \"2\": [\".####.\", \"##..##\", \"....##\", \"..###.\", \".##...\", \"##....\", \"######\"],\n \"3\": [\".####.\", \"##..##\", \"....##\", \"..###.\", \"....##\", \"##..##\", \".####.\"],\n \"4\": [\"##..##\", \"##..##\", \"##..##\", \"######\", \"....##\", \"....##\", \"....##\"],\n \"5\": [\"######\", \"##....\", \"#####.\", \"....##\", \"....##\", \"##..##\", \".####.\"],\n \"6\": [\".####.\", \"##....\", \"#####.\", \"##..##\", \"##..##\", \"##..##\", \".####.\"],\n \"7\": [\"######\", \"....##\", \"...##.\", \"..##..\", \"..##..\", \"..##..\", \"..##..\"],\n \"8\": [\".####.\", \"##..##\", \"##..##\", \".####.\", \"##..##\", \"##..##\", \".####.\"],\n \"9\": [\".####.\", \"##..##\", \"##..##\", \".#####\", \"....##\", \"....##\", \".####.\"],\n}\nconst DIGIT_W = 6\nconst DIGIT_H = 7\nconst DIGIT_GAP = 2\n\n/** 3×5 score digits, one row per string. */\nconst SCORE_DIGITS = [\n \"111101101101111\",\n \"010110010010111\",\n \"111001111100111\",\n \"111001111001111\",\n \"101101111001001\",\n \"111100111001111\",\n \"111100111101111\",\n \"111001001001001\",\n \"111101111101111\",\n \"111101111001111\",\n] as const\n\nconst BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5] 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: Snake404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\n/** Digits of `code` that have a glyph, `\"404\"` when none do. */\nexport function codeDigits(code: string | undefined): string {\n const digits = (code ?? \"\").replace(/[^0-9]/g, \"\").slice(0, 6)\n return digits || \"404\"\n}\n\nfunction easeOutBack(t: number) {\n const c = 1.70158\n const u = t - 1\n return 1 + (c + 1) * u * u * u + c * u * u\n}\n\nconst KEYS: Record = {\n ArrowRight: 0,\n ArrowDown: 1,\n ArrowLeft: 2,\n ArrowUp: 3,\n d: 0,\n s: 1,\n a: 2,\n w: 3,\n D: 0,\n S: 1,\n A: 2,\n W: 3,\n}\n\ntype Phase = \"wait\" | \"run\" | \"dead\"\n\nexport function createSnake404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Snake404Options = {}\n): Snake404Instance | null {\n let options: Snake404Options = {\n code: \"404\",\n cellSize: 12,\n speed: 14,\n maxLength: 32,\n grid: true,\n lcd: false,\n playable: true,\n score: true,\n smooth: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let visible = true\n let running = true\n let raf = 0\n let lastNow = performance.now()\n\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cell = 12\n let cols = 0\n let rows = 0\n let ox = 0\n let oy = 0\n let builtKey = \"\"\n\n // Field\n let food = new Uint8Array(0)\n let born = new Float64Array(0)\n let glyph: number[] = []\n let remaining = 0\n let refillAt = 0\n\n // Snake, head first. Cells may sit off the grid while it slides in.\n let bx: number[] = []\n let by: number[] = []\n let fed: boolean[] = []\n let occ = new Uint8Array(0)\n let dir: Snake404Direction = 0\n let nextX = 0\n let nextY = 0\n let growing = false\n let phase: Phase = \"wait\"\n let enterAt = 0\n let deadAt = 0\n let acc = 0\n let score = 0\n\n let playing = false\n let queue: Snake404Direction[] = []\n\n // Search scratch, reused every step.\n let seen = new Uint32Array(0)\n let dist = new Int32Array(0)\n let bfs = new Int32Array(0)\n let stamp = 0\n\n // Unlit LCD cells, drawn once per layout and ink.\n const ghost = document.createElement(\"canvas\")\n let ghostKey = \"\"\n\n const inkOf = () =>\n options.color ?? (options.lcd ? LCD_INK : dark ? DARK_COLOR : LIGHT_COLOR)\n\n const inGrid = (x: number, y: number) =>\n x >= 0 && y >= 0 && x < cols && y < rows\n\n const tailCell = () => {\n const i = bx.length - 1\n return inGrid(bx[i]!, by[i]!) ? by[i]! * cols + bx[i]! : -1\n }\n\n /** Free next step: empty, or the tail cell that moves out this step. */\n const passable = (x: number, y: number, freeTail: number) => {\n if (!inGrid(x, y)) return false\n const i = y * cols + x\n return occ[i] === 0 || (i === freeTail && occ[i] === 1)\n }\n\n const nextStamp = () => {\n stamp++\n if (stamp === 0xffffffff) {\n seen.fill(0)\n stamp = 1\n }\n return stamp\n }\n\n /** Cells reachable from `start`, stopping once it passes `cap`. */\n const flood = (start: number, freeTail: number, cap: number) => {\n const s = nextStamp()\n // The head moves to `start`, so the old head cell stays blocked.\n seen[start] = s\n bfs[0] = start\n let head = 0\n let tail = 1\n while (head < tail) {\n const c = bfs[head++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (let d = 0; d < 4; d++) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n bfs[tail++] = n\n if (tail > cap) return tail\n }\n }\n return tail\n }\n\n const visibleFood = (i: number, now: number) => food[i] === 1 && born[i]! <= now\n\n /** Autopilot: shortest way to the nearest pellet that will not trap it. */\n const autopilot = (freeTail: number, now: number): Snake404Direction => {\n const hx = bx[0]!\n const hy = by[0]!\n if (!inGrid(hx, hy)) return dir\n const head = hy * cols + hx\n const order = [dir, (dir + 3) % 4, (dir + 1) % 4] as Snake404Direction[]\n const len = bx.length\n\n // Nearest pellet by path length.\n let target = -1\n if (remaining > 0) {\n const s = nextStamp()\n seen[head] = s\n bfs[0] = head\n let qh = 0\n let qt = 1\n search: while (qh < qt) {\n const c = bfs[qh++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (const d of order) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n if (visibleFood(n, now)) {\n target = n\n break search\n }\n bfs[qt++] = n\n }\n }\n }\n\n let pick = -1\n if (target >= 0) {\n // Distances back from the pellet; step to the neighbor closest to it,\n // keeping straight on ties so paths turn once instead of zigzagging.\n const s = nextStamp()\n seen[target] = s\n dist[target] = 0\n bfs[0] = target\n let qh = 0\n let qt = 1\n while (qh < qt) {\n const c = bfs[qh++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (let d = 0; d < 4; d++) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n dist[n] = dist[c]! + 1\n bfs[qt++] = n\n }\n }\n let best = Infinity\n for (const d of order) {\n const nx = hx + DX[d]!\n const ny = hy + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] !== s) continue\n if (dist[n]! < best) {\n best = dist[n]!\n pick = d\n }\n }\n if (pick >= 0) {\n const n = (hy + DY[pick]!) * cols + hx + DX[pick]!\n if (flood(n, freeTail, len) < len) pick = -1\n }\n }\n if (pick >= 0) return pick as Snake404Direction\n\n // No safe pellet: wander, mostly straight, away from walls and itself.\n const cap = len * 2 + 8\n let bestArea = -1\n let bestDir = dir\n const ahead = (d: number) =>\n passable(hx + DX[d]! * 2, hy + DY[d]! * 2, freeTail) ? 1 : 0\n const wander =\n Math.random() < 0.08 ? [order[1]!, order[2]!, order[0]!] : order\n for (const d of wander) {\n const nx = hx + DX[d]!\n const ny = hy + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const area = flood(ny * cols + nx, freeTail, cap) * 2 + ahead(d)\n if (area > bestArea) {\n bestArea = area\n bestDir = d\n }\n }\n return bestDir\n }\n\n const die = (now: number) => {\n phase = \"dead\"\n deadAt = now\n queue = []\n if (playing) {\n playing = false\n options.onPlayChange?.(false)\n }\n }\n\n /** Decide the move for the step now starting. */\n const plan = (now: number) => {\n const last = bx.length - 1\n growing = fed[last]! && bx.length < Math.max(START_LENGTH, options.maxLength ?? 32)\n const freeTail = growing ? -1 : tailCell()\n let d = dir\n if (playing) {\n if (queue.length) d = queue.shift()!\n } else {\n d = autopilot(freeTail, now)\n }\n const nx = bx[0]! + DX[d]!\n const ny = by[0]! + DY[d]!\n if (!passable(nx, ny, freeTail)) {\n die(now)\n return\n }\n dir = d\n nextX = nx\n nextY = ny\n }\n\n /** Finish the step: the tail follows, the head lands and maybe eats. */\n const commit = (now: number) => {\n const last = bx.length - 1\n if (growing) {\n // The bulge reached the tail; the tail holds and the snake is longer.\n fed[last] = false\n } else {\n if (inGrid(bx[last]!, by[last]!)) {\n const t = by[last]! * cols + bx[last]!\n occ[t] = occ[t]! - 1\n }\n bx.pop()\n by.pop()\n fed.pop()\n }\n bx.unshift(nextX)\n by.unshift(nextY)\n const i = nextY * cols + nextX\n occ[i] = occ[i]! + 1\n const ate = food[i] === 1\n fed.unshift(ate)\n if (ate) {\n food[i] = 0\n remaining--\n score++\n options.onEat?.(remaining, glyph.length)\n if (remaining === 0) {\n refillAt = now + REFILL_DELAY_MS\n options.onClear?.()\n }\n }\n }\n\n const spawn = (now: number, forward = true) => {\n const row = Math.floor(rows / 2)\n // Slide in from the left edge; reduced motion parks it on screen.\n const onScreen = reduce\n bx = []\n by = []\n fed = []\n occ.fill(0)\n for (let k = 0; k < START_LENGTH; k++) {\n const x = onScreen ? START_LENGTH - k : -1 - k\n bx.push(x)\n by.push(row)\n fed.push(false)\n if (inGrid(x, row)) occ[row * cols + x] = 1\n }\n dir = 0\n nextX = bx[0]! + 1\n nextY = row\n growing = false\n acc = 0\n score = 0\n queue = []\n phase = onScreen ? \"run\" : \"wait\"\n enterAt = now + ENTER_DELAY_MS\n if (onScreen && forward) fastForward(now)\n }\n\n /**\n * Reduced motion holds one frame, so make it a meal in progress: run the\n * autopilot until part of the code is eaten and food is right ahead.\n */\n const fastForward = (now: number) => {\n const goal = Math.ceil(glyph.length * STILL_EATEN)\n let steps = cols * rows * 2\n let extra = 24\n while (phase === \"run\" && steps-- > 0) {\n if (glyph.length - remaining >= goal) {\n const ahead = inGrid(nextX, nextY) && visibleFood(nextY * cols + nextX, now)\n if (ahead || extra-- <= 0) break\n }\n commit(now)\n plan(now)\n }\n // A wrong turn ends the run; park it at the edge instead.\n if (phase === \"dead\") spawn(now, false)\n }\n\n const refill = (now: number) => {\n const span = Math.max(1, cols + rows)\n for (const i of glyph) {\n if (food[i] === 1) continue\n const x = i % cols\n const y = (i / cols) | 0\n food[i] = 1\n born[i] =\n now + ((x + y) / span) * REFILL_MS * 2 + (BAYER4[(y & 3) * 4 + (x & 3)]! / 16) * 140\n remaining++\n }\n }\n\n const layout = (now: number) => {\n cell = Math.max(4, Math.round(options.cellSize ?? 12))\n cols = Math.max(1, Math.floor(cssW / cell))\n rows = Math.max(1, Math.floor(cssH / cell))\n ox = Math.floor((cssW - cols * cell) / 2)\n oy = Math.floor((cssH - rows * cell) / 2)\n const n = cols * rows\n food = new Uint8Array(n)\n born = new Float64Array(n)\n occ = new Uint8Array(n)\n seen = new Uint32Array(n)\n dist = new Int32Array(n)\n bfs = new Int32Array(n + 4)\n stamp = 0\n glyph = []\n remaining = 0\n refillAt = 0\n\n const digits = codeDigits(options.code)\n const bw = digits.length * DIGIT_W + (digits.length - 1) * DIGIT_GAP\n const k = Math.max(\n 1,\n Math.floor(Math.min((cols * 0.74) / bw, (rows * 0.5) / DIGIT_H))\n )\n const gx0 = Math.floor((cols - bw * k) / 2)\n const gy0 = Math.floor((rows - DIGIT_H * k) / 2)\n for (let di = 0; di < digits.length; di++) {\n const bitmap = DIGITS[digits[di]!]!\n const left = gx0 + di * (DIGIT_W + DIGIT_GAP) * k\n for (let r = 0; r < DIGIT_H; r++) {\n for (let c = 0; c < DIGIT_W; c++) {\n if (bitmap[r]![c] !== \"#\") continue\n for (let sy = 0; sy < k; sy++) {\n for (let sx = 0; sx < k; sx++) {\n const x = left + c * k + sx\n const y = gy0 + r * k + sy\n if (!inGrid(x, y)) continue\n const i = y * cols + x\n food[i] = 1\n glyph.push(i)\n remaining++\n }\n }\n }\n }\n }\n if (playing) {\n playing = false\n options.onPlayChange?.(false)\n }\n spawn(now)\n }\n\n /** Returns whether the backing store changed (which clears the canvas). */\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return false\n cssW = w\n cssH = h\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n const changed = width !== canvas.width || height !== canvas.height\n if (changed) {\n canvas.width = width\n canvas.height = height\n }\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n const c = Math.max(4, Math.round(options.cellSize ?? 12))\n const key = `${Math.floor(w / c)}x${Math.floor(h / c)}:${c}:${codeDigits(options.code)}`\n if (key !== builtKey) {\n builtKey = key\n layout(performance.now())\n }\n return changed\n }\n\n const drawGhost = (ink: string) => {\n const key = `${canvas.width}x${canvas.height}:${cols}x${rows}:${cell}:${ink}:${options.lcd}:${dark}`\n if (key === ghostKey) return\n ghostKey = key\n ghost.width = canvas.width\n ghost.height = canvas.height\n const g = ghost.getContext(\"2d\")\n if (!g) return\n g.setTransform(dpr, 0, 0, dpr, 0, 0)\n g.clearRect(0, 0, cssW, cssH)\n g.fillStyle = ink\n g.globalAlpha = options.lcd ? 0.07 : dark ? 0.05 : 0.035\n const inset = Math.max(1, Math.round(cell * 0.16))\n const size = cell - inset * 2\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n g.fillRect(ox + x * cell + inset, oy + y * cell + inset, size, size)\n }\n }\n }\n\n const draw = (now: number, p: number) => {\n const ink = inkOf()\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.clearRect(0, 0, cssW, cssH)\n if (options.lcd) {\n ctx.fillStyle = LCD_SCREEN\n ctx.fillRect(0, 0, cssW, cssH)\n }\n if (options.grid) {\n drawGhost(ink)\n ctx.drawImage(ghost, 0, 0, cssW, cssH)\n }\n\n ctx.fillStyle = ink\n\n // Food: one pellet per lit cell, popping in after a refill.\n const inset = Math.max(1, Math.round(cell * 0.16))\n const pellet = cell - inset * 2\n for (const i of glyph) {\n if (food[i] !== 1) continue\n const t = (now - born[i]!) / POP_MS\n if (t < 0) continue\n const x = ox + (i % cols) * cell\n const y = oy + ((i / cols) | 0) * cell\n if (t >= 1) {\n ctx.fillRect(x + inset, y + inset, pellet, pellet)\n } else {\n const s = pellet * easeOutBack(t)\n const o = (cell - s) / 2\n ctx.fillRect(x + o, y + o, s, s)\n }\n }\n\n // Snake.\n const dead = phase === \"dead\"\n const blink = Math.floor((now - deadAt) / BLINK_MS)\n const shown = !dead || (blink < BLINKS && blink % 2 === 1)\n if (shown && bx.length > 0) {\n // Body as thick as a pellet; a meal swells it to nearly a full cell.\n const th = pellet\n const half = th / 2\n const cx = (x: number) => ox + (x + 0.5) * cell\n const cy = (y: number) => oy + (y + 0.5) * cell\n const moving = phase === \"run\"\n const t = moving ? p : 0\n const last = bx.length - 1\n\n const hx = cx(bx[0]! + (moving ? (nextX - bx[0]!) * t : 0))\n const hy = cy(by[0]! + (moving ? (nextY - by[0]!) * t : 0))\n const tailT = moving && !growing ? t : 0\n const tx = cx(bx[last]! + (bx[Math.max(0, last - 1)]! - bx[last]!) * tailT)\n const ty = cy(by[last]! + (by[Math.max(0, last - 1)]! - by[last]!) * tailT)\n\n const band = (ax: number, ay: number, bx2: number, by2: number) => {\n ctx.fillRect(\n Math.min(ax, bx2) - half,\n Math.min(ay, by2) - half,\n Math.abs(ax - bx2) + th,\n Math.abs(ay - by2) + th\n )\n }\n\n let px = hx\n let py = hy\n for (let k = 0; k < last; k++) {\n const qx = cx(bx[k]!)\n const qy = cy(by[k]!)\n band(px, py, qx, qy)\n px = qx\n py = qy\n }\n band(px, py, tx, ty)\n\n // Swallowed pellets: a bulge that stays put while the body slides by.\n const swell = cell - Math.max(1, Math.round(cell * 0.06)) * 2\n for (let k = 1; k <= last; k++) {\n if (!fed[k] || (k === last && !growing)) continue\n ctx.fillRect(cx(bx[k]!) - swell / 2, cy(by[k]!) - swell / 2, swell, swell)\n }\n\n // Eye, and a mouth that opens when food is next.\n const fx = DX[dir]!\n const fy = DY[dir]!\n const lx = fy\n const ly = -fx\n const cut = (x: number, y: number, w: number, h: number) => {\n if (options.lcd) {\n ctx.fillStyle = LCD_SCREEN\n ctx.fillRect(x, y, w, h)\n ctx.fillStyle = ink\n return\n }\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillRect(x, y, w, h)\n ctx.globalCompositeOperation = \"source-over\"\n }\n // The head is a full swell, so the eye and mouth have room.\n ctx.fillRect(hx - swell / 2, hy - swell / 2, swell, swell)\n const eye = Math.max(2, Math.round(cell * 0.2))\n const ex = hx - fx * swell * 0.06 + lx * swell * 0.22\n const ey = hy - fy * swell * 0.06 + ly * swell * 0.22\n cut(ex - eye / 2, ey - eye / 2, eye, eye)\n if (moving && inGrid(nextX, nextY) && visibleFood(nextY * cols + nextX, now)) {\n const w = Math.max(2, Math.round(swell * 0.36))\n const depth = Math.max(1, Math.round(swell * 0.3))\n const mx = hx + fx * (swell / 2 - depth / 2)\n const my = hy + fy * (swell / 2 - depth / 2)\n const mw = fx !== 0 ? depth : w\n const mh = fx !== 0 ? w : depth\n cut(mx - mw / 2, my - mh / 2, mw, mh)\n }\n }\n\n if (options.score) {\n const dot = Math.max(2, Math.round(cell / 3))\n const digits = String(score).padStart(4, \"0\")\n const x0 = ox + cell * 2\n const y0 = oy + cell * 2\n ctx.globalAlpha = 0.6\n for (let d = 0; d < digits.length; d++) {\n const bits = SCORE_DIGITS[digits.charCodeAt(d) - 48]!\n for (let b = 0; b < 15; b++) {\n if (bits[b] !== \"1\") continue\n ctx.fillRect(\n x0 + d * dot * 4 + (b % 3) * dot,\n y0 + Math.floor(b / 3) * dot,\n dot,\n dot\n )\n }\n }\n ctx.globalAlpha = 1\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(100, now - lastNow)\n lastNow = now\n\n if (refillAt && now >= refillAt) {\n refillAt = 0\n refill(now)\n }\n if (phase === \"wait\" && now >= enterAt && !reduce) phase = \"run\"\n if (phase === \"dead\" && now - deadAt >= DEATH_MS) spawn(now)\n\n const stepMs = 1000 / Math.max(1, options.speed ?? 14)\n if (phase === \"run\" && (!reduce || playing)) {\n acc += dt\n while (acc >= stepMs && phase === \"run\") {\n acc -= stepMs\n commit(now)\n plan(now)\n }\n }\n const p = options.smooth ? Math.min(1, acc / stepMs) : 0\n draw(now, p)\n\n if (visible) raf = requestAnimationFrame(tick)\n }\n\n const start = () => {\n cancelAnimationFrame(raf)\n lastNow = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const steer = (d: Snake404Direction) => {\n if (!options.playable || phase === \"dead\") return\n if (!playing) {\n playing = true\n queue = []\n options.onPlayChange?.(true)\n }\n if (phase === \"wait\") phase = \"run\"\n const last = queue.length ? queue[queue.length - 1]! : dir\n if (d === last || d === (last + 2) % 4) return\n if (queue.length < MAX_QUEUE) queue.push(d)\n }\n\n const handBack = () => {\n if (!playing) return\n playing = false\n queue = []\n options.onPlayChange?.(false)\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n reduce = mqReduce.matches\n const onReduce = () => {\n const was = reduce\n reduce = mqReduce.matches\n if (reduce !== was && !playing) spawn(performance.now())\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n resize()\n draw(performance.now(), 0)\n\n const ro = new ResizeObserver(() => {\n const changed = resize()\n if (changed && running) draw(performance.now(), 0)\n })\n ro.observe(wrap)\n\n const io = new IntersectionObserver(([entry]) => {\n const was = visible\n visible = entry?.isIntersecting ?? true\n if (visible && !was) start()\n })\n io.observe(wrap)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onKey = (e: KeyboardEvent) => {\n if (!options.playable || e.metaKey || e.ctrlKey || e.altKey) return\n if (e.key === \"Escape\") {\n if (playing) {\n e.preventDefault()\n handBack()\n }\n return\n }\n const d = KEYS[e.key]\n if (d === undefined) return\n e.preventDefault()\n steer(d)\n }\n\n let swipe: { id: number; x: number; y: number } | null = null\n const onDown = (e: PointerEvent) => {\n if (!options.playable) return\n swipe = { id: e.pointerId, x: e.clientX, y: e.clientY }\n }\n const onUp = (e: PointerEvent) => {\n if (!swipe || swipe.id !== e.pointerId) return\n const dx = e.clientX - swipe.x\n const dy = e.clientY - swipe.y\n swipe = null\n if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return\n if (Math.abs(dx) > Math.abs(dy)) steer(dx > 0 ? 0 : 2)\n else steer(dy > 0 ? 1 : 3)\n }\n const onCancel = () => {\n swipe = null\n }\n\n wrap.addEventListener(\"keydown\", onKey)\n wrap.addEventListener(\"focusout\", handBack)\n wrap.addEventListener(\"pointerdown\", onDown)\n wrap.addEventListener(\"pointerup\", onUp)\n wrap.addEventListener(\"pointercancel\", onCancel)\n\n start()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (\n options.cellSize !== prev.cellSize ||\n codeDigits(options.code) !== codeDigits(prev.code)\n ) {\n builtKey = \"\"\n resize()\n }\n if (options.theme !== prev.theme) syncTheme()\n if (!options.playable) handBack()\n if (!visible) draw(performance.now(), 0)\n },\n steer,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"keydown\", onKey)\n wrap.removeEventListener(\"focusout\", handBack)\n wrap.removeEventListener(\"pointerdown\", onDown)\n wrap.removeEventListener(\"pointerup\", onUp)\n wrap.removeEventListener(\"pointercancel\", onCancel)\n },\n }\n}\n\nexport type Snake404Props = Snake404Options & {\n /** Accessible name. Default `\", page not found\"`. */\n label?: string\n className?: string\n}\n\n/**\n * A dot-matrix 404 where every dot is food. A Nokia-style snake eats its\n * way through the digits, then they come back. Click and use the arrow\n * keys, WASD, or swipe to play.\n */\nexport function Snake404({\n code = \"404\",\n color,\n cellSize = 12,\n speed = 14,\n maxLength = 32,\n grid = true,\n lcd = false,\n playable = true,\n score = true,\n smooth = true,\n theme = \"auto\",\n onEat,\n onClear,\n onPlayChange,\n label,\n className,\n}: Snake404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacksRef = useRef({ onEat, onClear, onPlayChange })\n const [playing, setPlaying] = useState(false)\n\n useEffect(() => {\n callbacksRef.current = { onEat, onClear, onPlayChange }\n }, [onEat, onClear, onPlayChange])\n\n const options = {\n code,\n color,\n cellSize,\n speed,\n maxLength,\n grid,\n lcd,\n playable,\n score,\n smooth,\n theme,\n }\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createSnake404(wrap, canvas, {\n ...options,\n onEat: (left, total) => callbacksRef.current.onEat?.(left, total),\n onClear: () => callbacksRef.current.onClear?.(),\n onPlayChange: (next) => {\n setPlaying(next)\n callbacksRef.current.onPlayChange?.(next)\n },\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 code,\n color,\n cellSize,\n speed,\n maxLength,\n grid,\n lcd,\n playable,\n score,\n smooth,\n theme,\n ])\n\n const name = label ?? `${codeDigits(code)}, page not found`\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/snake-404.tsx" } diff --git a/public/r/stretchy-footer.json b/public/r/stretchy-footer.json index 8696a28..2791df8 100644 --- a/public/r/stretchy-footer.json +++ b/public/r/stretchy-footer.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/stretchy-footer/stretchy-footer.tsx", - "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\n/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */\nexport const DEFAULT_COLORS = [\n \"#FF1AE6\",\n \"#FF2E5E\",\n \"#FF8A1A\",\n \"#FFC81F\",\n \"#E2E6EC\",\n \"#8DB6E6\",\n \"#2F74DC\",\n \"#1459E3\",\n \"#1C2566\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 9\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 16\n/** White floor bloom opacity (0–1). Off by default. */\nexport const DEFAULT_GLOW = 0\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos: dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Edge columns stand this share of the centre one; the rest step evenly. */\nconst EDGE_COLUMN = 0.55\n/** The top of each column fades in over this share of its height. */\nconst TOP_FADE = 0.12\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /**\n * Gradient stops from the top of each column down to the floor, any CSS\n * colors. Every column carries the whole gradient, squeezed to its height.\n */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns, stepping down from the middle. Default `9`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `16`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0`. */\n glow?: number\n /**\n * Invert the pyramid: the edge columns stand tallest and the middle\n * dips, so the wobble ripples in from the sides. Default `false`.\n */\n flip?: boolean\n /**\n * `180` turns the aurora upside down in place: the columns hang from the\n * lifted page's edge and the gradient runs floor to top. Default `0`.\n */\n rotate?: 0 | 180\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/**\n * Stepped pyramid: the middle column is tallest, each step out is shorter.\n * Flipped, the edges are tallest and each step in is shorter.\n */\nexport function columnScale(\n index: number,\n count: number,\n flip = false\n): number {\n if (count <= 1) return 1\n const t = Math.abs((index / (count - 1)) * 2 - 1)\n return 1 - (1 - EDGE_COLUMN) * (flip ? 1 - t : t)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction: a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\nconst STRIP = 256\n\n/**\n * One column's gradient, `colors` spread from just under the top to the\n * floor, with the top fading in. Columns stretch it to their own height.\n */\nfunction makeStrip(colors: string[]) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = STRIP\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n const bands = g.createLinearGradient(0, 0, 0, STRIP)\n colors.forEach((color, i) => {\n const at = colors.length > 1 ? i / (colors.length - 1) : 1\n bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color)\n })\n g.fillStyle = bands\n g.fillRect(0, 0, 1, STRIP)\n const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, STRIP)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while a stepped pyramid of blurred gradient\n * columns stretches up from the floor (a valley with `flip`, hanging from\n * the page with `rotate: 180`); each column\n * has its own spring, so the edge wobbles as it snaps back. Idle unless\n * stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96))\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strip = document.createElement(\"canvas\")\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = columnScale(i, count, options.flip)\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrip = () => {\n strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway, so half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(stretch)\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n // Rotated, spin the drawing 180° about the gap between the lifted page\n // and the floor, so the columns hang from the page's edge.\n const rotated = options.rotate === 180\n if (rotated) {\n ctx!.setTransform(-1, 0, 0, -1, canvas.width, 2 * floor - stretch * scale)\n }\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n // The run-off under the floor would land on the page once rotated.\n if (rotated) continue\n ctx!.drawImage(\n strip,\n 0,\n STRIP - 1,\n 1,\n 1,\n x0,\n floor,\n x1 - x0,\n bottom - floor\n )\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const max = maxOf()\n const target = forced ?? rubberBand(distance, max)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrip()\n rebuildColumns()\n layout()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n const prevFlip = Boolean(options.flip)\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrip()\n if (countOf() !== columns.length || Boolean(options.flip) !== prevFlip) {\n rebuildColumns()\n }\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow: overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n flip = false,\n rotate = 0,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n {\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n demoId,\n }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n demoId,\n })\n }, [\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n demoId,\n ])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport; the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", + "content": "\"use client\"\n\nimport {\n useEffect,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\n/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */\nexport const DEFAULT_COLORS = [\n \"#FF1AE6\",\n \"#FF2E5E\",\n \"#FF8A1A\",\n \"#FFC81F\",\n \"#E2E6EC\",\n \"#8DB6E6\",\n \"#2F74DC\",\n \"#1459E3\",\n \"#1C2566\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 9\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 16\n/** White floor bloom opacity (0–1). Off by default. */\nexport const DEFAULT_GLOW = 0\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos: dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Edge columns stand this share of the centre one; the rest step evenly. */\nconst EDGE_COLUMN = 0.55\n/** The top of each column fades in over this share of its height. */\nconst TOP_FADE = 0.12\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /**\n * Gradient stops from the top of each column down to the floor, any CSS\n * colors. Every column carries the whole gradient, squeezed to its height.\n */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many aurora columns, stepping down from the middle. Default `9`. */\n columns?: number\n /** Gaussian blur on the aurora, in px. Default `16`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0`. */\n glow?: number\n /**\n * Invert the pyramid: the edge columns stand tallest and the middle\n * dips, so the wobble ripples in from the sides. Default `false`.\n */\n flip?: boolean\n /**\n * `180` turns the aurora upside down in place: the columns hang from the\n * lifted page's edge and the gradient runs floor to top. Default `0`.\n */\n rotate?: 0 | 180\n /**\n * Resting stretch as a share of `maxStretch`, 0–1: the aurora stays up\n * over the page floor even before anyone scrolls, overscroll stretches it\n * further and lifts the page, and it springs back to here. Default `0`.\n */\n peek?: number\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * plays footers whose `demoId` matches.\n */\n demoId?: string\n}\n\nexport type StretchyFooterMount = {\n /** Canvas the aurora paints into. Its parent sets the width. */\n canvas: HTMLCanvasElement\n /** Where overscroll is read: a scroll container, or `window`. */\n scroller: HTMLElement | Window\n /** Lifts with the stretch: an element, or a selector looked up when needed. */\n content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n amount?: number\n /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n holdMs?: number\n /** Only footers with this `demoId` respond. */\n target?: string\n /** Scroll this element (or the window) to the end before playing. */\n scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n setOptions: (options: Partial) => void\n /** Stretch and snap back, even under `prefers-reduced-motion`. */\n play: (detail?: Pick) => void\n destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n const el = root ?? document.documentElement\n const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n const top = () => (root ? root.scrollTop : window.scrollY)\n const scroll = (behavior: ScrollBehavior) =>\n (root ?? window).scrollTo({ top: max(), behavior })\n scroll(\"smooth\")\n const started = performance.now()\n while (performance.now() - started < 2000 && top() < max() - 2) {\n await new Promise((r) => window.setTimeout(r, 32))\n }\n scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n detail: StretchyFooterPlayDetail = {}\n) {\n if (typeof window === \"undefined\") return\n await scrollToEnd(detail.scrollRoot)\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, { detail })\n )\n // Travel out, hold, spring home.\n await new Promise((r) =>\n window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n )\n}\n\n/**\n * Stepped pyramid: the middle column is tallest, each step out is shorter.\n * Flipped, the edges are tallest and each step in is shorter.\n */\nexport function columnScale(\n index: number,\n count: number,\n flip = false\n): number {\n if (count <= 1) return 1\n const t = Math.abs((index / (count - 1)) * 2 - 1)\n return 1 - (1 - EDGE_COLUMN) * (flip ? 1 - t : t)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n if (max <= 0 || distance <= 0) return 0\n return max * (1 - 1 / ((distance * RUBBER) / max + 1))\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n if (event.deltaMode === 1) return event.deltaY * 16\n if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction: a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n target: EventTarget | null,\n root: Element,\n dy: number\n) {\n let el = target instanceof Element ? target : null\n while (el && el !== root && el !== document.documentElement) {\n if (el.scrollHeight > el.clientHeight + 1) {\n const overflow = getComputedStyle(el).overflowY\n if (overflow === \"auto\" || overflow === \"scroll\") {\n const room =\n dy > 0\n ? el.scrollHeight - el.clientHeight - el.scrollTop\n : el.scrollTop\n if (room > 1) return true\n }\n }\n el = el.parentElement\n }\n return false\n}\n\nconst STRIP = 256\n\n/**\n * One column's gradient, `colors` spread from just under the top to the\n * floor, with the top fading in. Columns stretch it to their own height.\n */\nfunction makeStrip(colors: string[]) {\n const strip = document.createElement(\"canvas\")\n strip.width = 1\n strip.height = STRIP\n const g = strip.getContext(\"2d\")\n if (!g) return strip\n const bands = g.createLinearGradient(0, 0, 0, STRIP)\n colors.forEach((color, i) => {\n const at = colors.length > 1 ? i / (colors.length - 1) : 1\n bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color)\n })\n g.fillStyle = bands\n g.fillRect(0, 0, 1, STRIP)\n const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE)\n fade.addColorStop(0, \"rgba(0,0,0,0)\")\n fade.addColorStop(1, \"rgba(0,0,0,1)\")\n g.globalCompositeOperation = \"destination-in\"\n g.fillStyle = fade\n g.fillRect(0, 0, 1, STRIP)\n return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n const bloom = document.createElement(\"canvas\")\n bloom.width = 128\n bloom.height = 64\n const g = bloom.getContext(\"2d\")\n if (!g) return bloom\n const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n grad.addColorStop(0, \"rgba(255,255,255,1)\")\n grad.addColorStop(1, \"rgba(255,255,255,0)\")\n g.fillStyle = grad\n g.fillRect(0, 0, 128, 64)\n return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n s.v += accel * dt\n s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while a stepped pyramid of blurred gradient\n * columns stretches up from the floor (a valley with `flip`, hanging from\n * the page with `rotate: 180`); each column\n * has its own spring, so the edge wobbles as it snaps back. Idle unless\n * stretched.\n */\nexport function createStretchyFooter(\n mount: StretchyFooterMount,\n initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n const { canvas, scroller } = mount\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let options: StretchyFooterOptions = { ...initial }\n const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n const stiffnessOf = () =>\n numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n /** Resting stretch in px; only overscroll past it lifts the page. */\n const peekOf = () => maxOf() * numberOr(options.peek, 0, 0, 1)\n const countOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96))\n /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n let distance = 0\n /** `play()` holds the stretch here, overriding the gesture. */\n let forced: number | null = null\n const lift: Spring = { x: 0, v: 0, target: 0 }\n let columns: (Spring & { share: number })[] = []\n let strip = document.createElement(\"canvas\")\n const bloom = makeBloom()\n\n const rebuildColumns = () => {\n const count = countOf()\n const target = lift.target\n columns = Array.from({ length: count }, (_, i) => {\n const share = columnScale(i, count, options.flip)\n return { x: lift.x * share, v: 0, target: target * share, share }\n })\n }\n const rebuildStrip = () => {\n strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS)\n }\n\n const size = { w: 0, pad: 0, scale: 1 }\n const layout = () => {\n const parent = canvas.parentElement\n const blur = blurOf()\n const max = maxOf()\n // Room past the edges, so the blur doesn't fade the sides and floor.\n const pad = Math.ceil(blur * 2)\n // Blurred anyway, so half resolution is plenty and half the fill.\n const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n size.w = (parent?.clientWidth ?? 0) + pad * 2\n size.pad = pad\n size.scale = scale\n canvas.width = Math.max(1, Math.ceil(size.w * scale))\n canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n Object.assign(canvas.style, {\n position: \"absolute\",\n left: `${-pad}px`,\n bottom: `${-pad}px`,\n width: `${size.w}px`,\n height: `${max + pad}px`,\n filter: blur > 0 ? `blur(${blur}px)` : \"\",\n pointerEvents: \"none\",\n })\n draw()\n }\n\n // The page element that rides the stretch.\n let lifted: HTMLElement | null = null\n let liftedTransform = \"\"\n const contentEl = () => {\n const content = mount.content\n if (!content) return null\n if (typeof content !== \"string\") return content\n if (lifted?.isConnected && lifted.matches(content)) return lifted\n const scope = scroller instanceof HTMLElement ? scroller : document\n return (\n scope.querySelector(content) ??\n document.querySelector(content)\n )\n }\n const liftContent = (y: number) => {\n const el = contentEl()\n if (el !== lifted) {\n if (lifted) lifted.style.transform = liftedTransform\n lifted = el\n liftedTransform = el?.style.transform ?? \"\"\n }\n if (!lifted) return\n if (y > 0.2) {\n lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n lifted.style.willChange = \"transform\"\n } else {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n }\n\n function draw() {\n const stretch = Math.max(0, lift.x)\n liftContent(Math.max(0, stretch - peekOf()))\n const max = maxOf()\n const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n if (!visible) return\n\n const { w, pad, scale } = size\n const floor = max * scale\n const bottom = canvas.height\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n ctx!.clearRect(0, 0, canvas.width, canvas.height)\n ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n // Rotated, spin the drawing 180° about the gap between the lifted page\n // and the floor, so the columns hang from the page's edge.\n const rotated = options.rotate === 180\n if (rotated) {\n ctx!.setTransform(-1, 0, 0, -1, canvas.width, 2 * floor - stretch * scale)\n }\n\n // Columns span the visible width; the outer two run on into the pad.\n const count = columns.length\n const inner = (w - pad * 2) * scale\n const edge = (i: number) =>\n i <= 0\n ? 0\n : i >= count\n ? canvas.width\n : Math.round(pad * scale + (inner * i) / count)\n for (let i = 0; i < count; i++) {\n const h = Math.max(0, columns[i]!.x) * scale\n if (h < 0.5) continue\n const x0 = edge(i)\n const x1 = edge(i + 1)\n ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n // The run-off under the floor would land on the page once rotated.\n if (rotated) continue\n ctx!.drawImage(\n strip,\n 0,\n STRIP - 1,\n 1,\n 1,\n x0,\n floor,\n x1 - x0,\n bottom - floor\n )\n }\n\n const glow = glowOf()\n if (glow > 0) {\n const rx = inner * 0.49\n const ry = stretch * 0.35 * scale\n ctx!.globalAlpha *= glow\n ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n }\n ctx!.globalAlpha = 1\n ctx!.setTransform(1, 0, 0, 1, 0, 0)\n }\n\n let raf = 0\n let last = 0\n const retarget = () => {\n const rest = peekOf()\n const target =\n forced === null\n ? rest + rubberBand(distance, maxOf() - rest)\n : Math.max(forced, rest)\n lift.target = target\n for (const column of columns) column.target = target * column.share\n if (!raf) {\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n }\n\n function frame(now: number) {\n raf = 0\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n const k = stiffnessOf()\n const c = dampingOf()\n const steps = Math.max(1, Math.ceil(dt / STEP))\n for (let n = 0; n < steps; n++) {\n stepSpring(lift, k, c, dt / steps)\n for (const column of columns) {\n // Short edge columns are softer and bouncier than the middle, so the\n // top edge ripples out from the centre.\n const ck = k * (0.6 + 0.4 * column.share)\n stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n }\n }\n const settled = atRest(lift) && columns.every(atRest)\n if (settled) {\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n }\n draw()\n if (!settled) raf = requestAnimationFrame(frame)\n }\n\n /** Jumps straight to the targets: no spring, for reduced motion. */\n const settle = () => {\n cancelAnimationFrame(raf)\n raf = 0\n lift.x = lift.target\n lift.v = 0\n for (const column of columns) {\n column.x = column.target\n column.v = 0\n }\n draw()\n }\n\n // Gestures.\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduce = mqReduce.matches\n let releaseTimer = 0\n let holdTimer = 0\n let lastWheel = 0\n let lastDelta = 0\n let coasting = 0\n let cooling = false\n let lastTouchY = 0\n\n const root =\n scroller instanceof HTMLElement ? scroller : document.documentElement\n const atBottom = () =>\n root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n const release = () => {\n window.clearTimeout(releaseTimer)\n releaseTimer = 0\n if (distance <= 0) return\n distance = 0\n cooling = true\n retarget()\n }\n const stopPlay = () => {\n if (forced === null) return\n window.clearTimeout(holdTimer)\n forced = null\n retarget()\n }\n\n const onWheel = (event: WheelEvent) => {\n if (reduce || event.ctrlKey) return\n const dy = wheelPixels(event)\n if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n stopPlay()\n const now = performance.now()\n const gap = now - lastWheel\n lastWheel = now\n\n if (distance > 0) {\n event.preventDefault()\n // A trackpad coasting out: deltas shrink every event. Treat it as\n // letting go instead of holding the stretch until momentum ends.\n coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n lastDelta = dy\n if (coasting >= COAST_EVENTS) {\n release()\n return\n }\n distance = Math.max(0, distance + dy)\n retarget()\n window.clearTimeout(releaseTimer)\n if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n return\n }\n\n // Momentum still arriving after a release shouldn't pull again.\n if (cooling) {\n if (gap < MOMENTUM_GAP_MS) return\n cooling = false\n }\n if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n event.preventDefault()\n distance = dy\n lastDelta = dy\n coasting = 0\n retarget()\n releaseTimer = window.setTimeout(release, RELEASE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch) return\n stopPlay()\n cooling = false\n lastTouchY = touch.clientY\n }\n const onTouchMove = (event: TouchEvent) => {\n const touch = event.touches[0]\n if (!touch || reduce) return\n const dy = lastTouchY - touch.clientY\n lastTouchY = touch.clientY\n if (\n distance > 0 ||\n (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n ) {\n if (event.cancelable) event.preventDefault()\n distance = Math.max(0, distance + dy)\n retarget()\n }\n }\n const onTouchEnd = () => release()\n\n const onPlayEvent = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n if ((detail.target ?? null) !== (options.demoId ?? null)) return\n instance.play(detail)\n }\n\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) release()\n }\n\n const target: HTMLElement | Window = scroller\n const prevOverscroll = root.style.overscrollBehaviorY\n // The browser's own rubber band would fight ours.\n root.style.overscrollBehaviorY = \"none\"\n target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n passive: true,\n })\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n passive: false,\n })\n target.addEventListener(\"touchend\", onTouchEnd)\n target.addEventListener(\"touchcancel\", onTouchEnd)\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.addEventListener(\"change\", onReduce)\n const ro = new ResizeObserver(layout)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n rebuildStrip()\n rebuildColumns()\n layout()\n // A peek rises into place on mount, or is simply there under reduced motion.\n retarget()\n if (reduce) settle()\n\n const instance: StretchyFooterInstance = {\n setOptions(next) {\n const prevColors = options.colors\n const prevMax = maxOf()\n const prevBlur = blurOf()\n const prevFlip = Boolean(options.flip)\n const prevPeek = peekOf()\n options = { ...options, ...next }\n if (options.colors !== prevColors) rebuildStrip()\n if (countOf() !== columns.length || Boolean(options.flip) !== prevFlip) {\n rebuildColumns()\n }\n if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n if (forced !== null) forced = Math.min(forced, maxOf())\n retarget()\n if (reduce && forced === null && peekOf() !== prevPeek) settle()\n },\n play({ amount = 0.82, holdMs = 700 } = {}) {\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n distance = 0\n forced = maxOf() * clamp(amount, 0, 1)\n retarget()\n holdTimer = window.setTimeout(stopPlay, holdMs)\n },\n destroy() {\n cancelAnimationFrame(raf)\n raf = 0\n window.clearTimeout(releaseTimer)\n window.clearTimeout(holdTimer)\n root.style.overscrollBehaviorY = prevOverscroll\n target.removeEventListener(\"wheel\", onWheel as EventListener)\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n target.removeEventListener(\"touchend\", onTouchEnd)\n target.removeEventListener(\"touchcancel\", onTouchEnd)\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n mqReduce.removeEventListener(\"change\", onReduce)\n ro.disconnect()\n if (lifted) {\n lifted.style.transform = liftedTransform\n lifted.style.willChange = \"\"\n }\n },\n }\n return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n className?: string\n /**\n * Page content inside the overflow scroller.\n * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n */\n children?: ReactNode\n /**\n * External scroll container. When set, this component only paints the\n * aurora overlay and binds overscroll to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages instead of a nested scroller.\n */\n windowScroll?: boolean\n /**\n * Element that lifts with the stretch (CSS selector).\n * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n */\n contentSelector?: string\n /** Accessible label for the scroll region. */\n label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow: overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = DEFAULT_MAX_STRETCH,\n colors = DEFAULT_COLORS,\n stiffness = DEFAULT_STIFFNESS,\n damping = DEFAULT_DAMPING,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n flip = false,\n rotate = 0,\n peek = 0,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const canvasRef = useRef(null)\n const scrollerRef = useRef(null)\n const contentRef = useRef(null)\n const instanceRef = useRef(null)\n // The window aurora portals to , which only exists on the client.\n const onClient = useSyncExternalStore(\n noopSubscribe,\n () => true,\n () => false\n )\n const portalHost = windowScroll && onClient ? document.body : null\n\n useEffect(() => {\n const canvas = canvasRef.current\n const scroller = windowScroll\n ? window\n : (scrollRef?.current ?? scrollerRef.current)\n if (!canvas || !scroller) return\n const content =\n windowScroll || scrollRef ? contentSelector : contentRef.current\n instanceRef.current = createStretchyFooter(\n { canvas, scroller, content },\n {\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n peek,\n demoId,\n }\n )\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Options stream in through setOptions; only the wiring remounts.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n peek,\n demoId,\n })\n }, [\n maxStretch,\n colors,\n stiffness,\n damping,\n columns,\n blur,\n glow,\n flip,\n rotate,\n peek,\n demoId,\n ])\n\n const aurora = (\n \n \n \n )\n\n if (windowScroll) {\n if (!portalHost) return null\n // Portal so `fixed` stays on the viewport; the lifted page is\n // transformed and would otherwise pin it to the content.\n return createPortal(\n \n {aurora}\n ,\n portalHost\n )\n }\n\n if (scrollRef) {\n return (\n \n {aurora}\n \n )\n }\n\n // The aurora sits beside the scroller, not in it, so it stays on the\n // viewport floor instead of scrolling away with the content.\n return (\n \n \n
\n {children}\n
\n \n {aurora}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/stretchy-footer.tsx" } diff --git a/registry/dither-scan/dither-scan-vanilla.ts b/registry/dither-scan/dither-scan-vanilla.ts index e49d7de..327b44c 100644 --- a/registry/dither-scan/dither-scan-vanilla.ts +++ b/registry/dither-scan/dither-scan-vanilla.ts @@ -31,6 +31,12 @@ export type DitherScanOptions = { loop?: boolean /** Hold on the clean image before a loop, in ms. Default `2400` */ loopDelay?: number + /** + * Under `prefers-reduced-motion`, hold one frame with the line this far + * across, 0–1: the 1-bit print ahead of it, the clean image behind. + * Unset shows the clean image. + */ + reducedMotionProgress?: number /** * Palette mode. Default `auto` follows shadcn / next-themes * (`html.dark` class). @@ -248,6 +254,8 @@ const SWEEP = 1 const HOLD = 2 const DONE = 3 const RESET = 4 +/** Reduced motion: one frame, the line resting part way across. */ +const STILL = 5 export function createDitherScan( wrap: HTMLElement, @@ -517,6 +525,11 @@ export function createDitherScan( return } + if (state === STILL) { + drawStill() + return + } + drawLevel(pass) if (state !== SWEEP) return @@ -556,6 +569,34 @@ export function createDitherScan( ctx.restore() } + /** The 1-bit print with the clean image up to the resting line. */ + const drawStill = () => { + const at = Math.min(1, Math.max(0, options.reducedMotionProgress ?? 0)) + const a = ((options.angle ?? 45) * Math.PI) / 180 + const cos = Math.abs(Math.cos(a)) + const sin = Math.abs(Math.sin(a)) + const e = (cos * cssW + sin * cssH) / 2 + const f = (sin * cssW + cos * cssH) / 2 + 2 + const u = -e + 2 * e * at + + drawLevel(0) + 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(levels.length - 1) + ctx.restore() + + 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 @@ -624,6 +665,13 @@ export function createDitherScan( const begin = (now = performance.now()) => { wantsPlay = false const last = levels.length - 1 + if (reduce && last > 0 && options.reducedMotionProgress != null) { + pass = 0 + state = STILL + setScanning(false) + redraw() + return + } if (last <= 0 || reduce) { pass = Math.max(0, last) state = DONE @@ -778,6 +826,14 @@ export function createDitherScan( return } if (prev.loop !== options.loop && state === DONE) scheduleLoop() + if ( + reduce && + prev.reducedMotionProgress !== options.reducedMotionProgress && + (state === DONE || state === STILL) + ) { + begin() + return + } if (prev.autoPlay === false && options.autoPlay !== false && state === IDLE) { requestPlay() } diff --git a/registry/dither-scan/dither-scan.tsx b/registry/dither-scan/dither-scan.tsx index 82aa4c0..d0ea393 100644 --- a/registry/dither-scan/dither-scan.tsx +++ b/registry/dither-scan/dither-scan.tsx @@ -50,6 +50,7 @@ export function DitherScan({ autoPlay = true, loop = false, loopDelay = 2400, + reducedMotionProgress, theme = "auto", replayKey = 0, onStart, @@ -87,6 +88,7 @@ export function DitherScan({ autoPlay, loop, loopDelay, + reducedMotionProgress, theme, } @@ -130,6 +132,7 @@ export function DitherScan({ autoPlay, loop, loopDelay, + reducedMotionProgress, theme, ]) diff --git a/registry/mech-keyboard/mech-keyboard.tsx b/registry/mech-keyboard/mech-keyboard.tsx index bd3a442..c32befa 100644 --- a/registry/mech-keyboard/mech-keyboard.tsx +++ b/registry/mech-keyboard/mech-keyboard.tsx @@ -50,6 +50,11 @@ export type MechKeyboardProps = { recorder?: boolean /** Recorder text before the first key. */ placeholder?: string + /** + * `KeyboardEvent.code`s the recorder starts on, shown as saved until the + * next key, e.g. the current binding: `["MetaLeft", "KeyK"]`. + */ + defaultShortcut?: string[] /** `KeyboardEvent.code`s to pulse, e.g. `["MetaLeft", "KeyK"]`. */ highlight?: string[] /** Bottom-row legends. `"auto"` detects Mac. */ @@ -96,6 +101,7 @@ export function MechKeyboard({ interactive = true, recorder = true, placeholder = DEFAULT_PLACEHOLDER, + defaultShortcut, highlight, platform = "auto", sound = false, @@ -116,7 +122,11 @@ export function MechKeyboard({ const [chord, setChord] = useState<{ codes: string[] phase: MechKeyboardChordPhase - }>({ codes: [], phase: "idle" }) + }>(() => + defaultShortcut?.length + ? { codes: defaultShortcut, phase: "done" } + : { codes: [], phase: "idle" } + ) const resolvedPlatform = platform === "auto" ? detected : platform const shortcut = formatShortcut(chord.codes, resolvedPlatform) diff --git a/registry/snake-404/snake-404-vanilla.ts b/registry/snake-404/snake-404-vanilla.ts index fbde6ca..cbca7d1 100644 --- a/registry/snake-404/snake-404-vanilla.ts +++ b/registry/snake-404/snake-404-vanilla.ts @@ -63,6 +63,8 @@ const BLINK_MS = 150 const BLINKS = 6 const MAX_QUEUE = 3 const SWIPE_PX = 24 +/** Share of the digits already eaten when reduced motion holds the frame. */ +const STILL_EATEN = 0.12 const DX = [1, 0, -1, 0] as const const DY = [0, 1, 0, -1] as const @@ -443,7 +445,7 @@ export function createSnake404( } } - const spawn = (now: number) => { + const spawn = (now: number, forward = true) => { const row = Math.floor(rows / 2) // Slide in from the left edge; reduced motion parks it on screen. const onScreen = reduce @@ -467,6 +469,27 @@ export function createSnake404( queue = [] phase = onScreen ? "run" : "wait" enterAt = now + ENTER_DELAY_MS + if (onScreen && forward) fastForward(now) + } + + /** + * Reduced motion holds one frame, so make it a meal in progress: run the + * autopilot until part of the code is eaten and food is right ahead. + */ + const fastForward = (now: number) => { + const goal = Math.ceil(glyph.length * STILL_EATEN) + let steps = cols * rows * 2 + let extra = 24 + while (phase === "run" && steps-- > 0) { + if (glyph.length - remaining >= goal) { + const ahead = inGrid(nextX, nextY) && visibleFood(nextY * cols + nextX, now) + if (ahead || extra-- <= 0) break + } + commit(now) + plan(now) + } + // A wrong turn ends the run; park it at the edge instead. + if (phase === "dead") spawn(now, false) } const refill = (now: number) => { @@ -696,10 +719,10 @@ export function createSnake404( } if (options.score) { - const dot = Math.max(2, Math.round(cell / 4)) + const dot = Math.max(2, Math.round(cell / 3)) const digits = String(score).padStart(4, "0") - const x0 = ox + cell - const y0 = oy + cell + const x0 = ox + cell * 2 + const y0 = oy + cell * 2 ctx.globalAlpha = 0.6 for (let d = 0; d < digits.length; d++) { const bits = SCORE_DIGITS[digits.charCodeAt(d) - 48]! diff --git a/registry/stretchy-footer/stretchy-footer-vanilla.ts b/registry/stretchy-footer/stretchy-footer-vanilla.ts index ae97d31..db42ba0 100644 --- a/registry/stretchy-footer/stretchy-footer-vanilla.ts +++ b/registry/stretchy-footer/stretchy-footer-vanilla.ts @@ -70,6 +70,12 @@ export type StretchyFooterOptions = { * lifted page's edge and the gradient runs floor to top. Default `0`. */ rotate?: 0 | 180 + /** + * Resting stretch as a share of `maxStretch`, 0–1: the aurora stays up + * over the page floor even before anyone scrolls, overscroll stretches it + * further and lifts the page, and it springs back to here. Default `0`. + */ + peek?: number /** * Optional id for docs demos. `playStretchyFooterDemo({ target })` only * plays footers whose `demoId` matches. @@ -272,6 +278,8 @@ export function createStretchyFooter( const stiffnessOf = () => numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000) const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400) + /** Resting stretch in px; only overscroll past it lifts the page. */ + const peekOf = () => maxOf() * numberOr(options.peek, 0, 0, 1) const countOf = () => Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96)) /** Overscroll the user has put in, in px. The stretch is its rubber band. */ @@ -354,7 +362,7 @@ export function createStretchyFooter( function draw() { const stretch = Math.max(0, lift.x) - liftContent(stretch) + liftContent(Math.max(0, stretch - peekOf())) const max = maxOf() const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2) canvas.style.visibility = visible ? "visible" : "hidden" @@ -417,8 +425,11 @@ export function createStretchyFooter( let raf = 0 let last = 0 const retarget = () => { - const max = maxOf() - const target = forced ?? rubberBand(distance, max) + const rest = peekOf() + const target = + forced === null + ? rest + rubberBand(distance, maxOf() - rest) + : Math.max(forced, rest) lift.target = target for (const column of columns) column.target = target * column.share if (!raf) { @@ -456,6 +467,19 @@ export function createStretchyFooter( if (!settled) raf = requestAnimationFrame(frame) } + /** Jumps straight to the targets: no spring, for reduced motion. */ + const settle = () => { + cancelAnimationFrame(raf) + raf = 0 + lift.x = lift.target + lift.v = 0 + for (const column of columns) { + column.x = column.target + column.v = 0 + } + draw() + } + // Gestures. const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") let reduce = mqReduce.matches @@ -584,6 +608,9 @@ export function createStretchyFooter( rebuildStrip() rebuildColumns() layout() + // A peek rises into place on mount, or is simply there under reduced motion. + retarget() + if (reduce) settle() const instance: StretchyFooterInstance = { setOptions(next) { @@ -591,6 +618,7 @@ export function createStretchyFooter( const prevMax = maxOf() const prevBlur = blurOf() const prevFlip = Boolean(options.flip) + const prevPeek = peekOf() options = { ...options, ...next } if (options.colors !== prevColors) rebuildStrip() if (countOf() !== columns.length || Boolean(options.flip) !== prevFlip) { @@ -599,6 +627,7 @@ export function createStretchyFooter( if (maxOf() !== prevMax || blurOf() !== prevBlur) layout() if (forced !== null) forced = Math.min(forced, maxOf()) retarget() + if (reduce && forced === null && peekOf() !== prevPeek) settle() }, play({ amount = 0.82, holdMs = 700 } = {}) { window.clearTimeout(releaseTimer) diff --git a/registry/stretchy-footer/stretchy-footer.tsx b/registry/stretchy-footer/stretchy-footer.tsx index ed1074b..da94755 100644 --- a/registry/stretchy-footer/stretchy-footer.tsx +++ b/registry/stretchy-footer/stretchy-footer.tsx @@ -90,6 +90,7 @@ export function StretchyFooter({ glow = DEFAULT_GLOW, flip = false, rotate = 0, + peek = 0, label = "Stretchy overflow", demoId, }: StretchyFooterProps) { @@ -125,6 +126,7 @@ export function StretchyFooter({ glow, flip, rotate, + peek, demoId, } ) @@ -147,6 +149,7 @@ export function StretchyFooter({ glow, flip, rotate, + peek, demoId, }) }, [ @@ -159,6 +162,7 @@ export function StretchyFooter({ glow, flip, rotate, + peek, demoId, ]) diff --git a/scripts/bundle-21st.mjs b/scripts/bundle-21st.mjs new file mode 100644 index 0000000..37a32d6 --- /dev/null +++ b/scripts/bundle-21st.mjs @@ -0,0 +1,39 @@ +// Bundles registry components for publishing on 21st.dev. +// The 21st CLI takes one self-contained component file plus one demo, so each +// component's vanilla engine is inlined and its demo from 21st/ is copied +// next to it. +// +// node scripts/bundle-21st.mjs [slug...] +// +// Writes //.tsx and //.demo.tsx. +// With no slugs, bundles every component that has a demo in 21st/. +import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs" +import { join, resolve } from "node:path" + +import { publishedFileContent } from "./registry-lib.mjs" + +const [outArg, ...picked] = process.argv.slice(2) +if (!outArg) { + console.error("usage: node scripts/bundle-21st.mjs [slug...]") + process.exit(1) +} + +const out = resolve(outArg) +const slugs = picked.length + ? picked + : readdirSync("21st") + .filter((f) => f.endsWith(".demo.tsx")) + .map((f) => f.replace(/\.demo\.tsx$/, "")) + +for (const slug of slugs) { + const demo = join("21st", `${slug}.demo.tsx`) + if (!existsSync(demo)) throw new Error(`no demo at ${demo}`) + const dir = join(out, slug) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, `${slug}.tsx`), + publishedFileContent(join("registry", slug), `${slug}.tsx`) + ) + copyFileSync(demo, join(dir, `${slug}.demo.tsx`)) + console.log(`bundled ${slug}`) +} diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 2e04ec7..8bb0641 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -276,6 +276,7 @@ type StretchyFooterProps = { glow?: number // 0, white floor bloom, 0–1 flip?: boolean // false; edge columns tallest, middle dips rotate?: 0 | 180 // 0; 180 hangs the aurora from the lifted page + peek?: number // 0; resting stretch, 0–1 of maxStretch, so the aurora shows before any scroll label?: string // "Stretchy overflow" demoId?: string } @@ -426,6 +427,7 @@ type DitherScanProps = { autoPlay?: boolean // true, starts when 35% in view loop?: boolean // false loopDelay?: number // 2400 ms on the clean image + reducedMotionProgress?: number // 0–1, reduced motion holds the line here; unset = clean theme?: "light" | "dark" | "auto" replayKey?: number // increment to re-dither and scan onStart?: () => void @@ -501,6 +503,7 @@ type MechKeyboardProps = { interactive?: boolean // true: window keys and clicks press keys recorder?: boolean // true: illustrated keycaps spelling out the chord placeholder?: string // "Press a shortcut" + defaultShortcut?: string[] // codes the recorder starts on, shown as saved highlight?: string[] // KeyboardEvent.code values to pulse platform?: "auto" | "mac" | "pc" // "auto": bottom-row legends and labels sound?: boolean // false: synthesized switch sounds diff --git a/tsconfig.json b/tsconfig.json index 13726db..25c8dfe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,5 +32,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules", "tmp-demos"] + "exclude": ["node_modules", "tmp-demos", "21st"] }