From e5bdb39063fcc611d4876eb8834da1380176ea87 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 20:11:42 +0530 Subject: [PATCH 1/2] Add a mechanical keyboard, drawn as an isometric illustration, that presses along with yours. A 75% board in three.js: milled case, volume knob, and 82 keycaps in flat tone bands with ink outlines traced from depth and normals. Real keys and clicks press the caps, and a recorder spells out the chord in illustrated keycaps for a create-a-shortcut onboarding step. Monochrome drops the color. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/SKILL.md | 6 +- .cursor/skills/23rd/apis.md | 31 + .cursor/skills/23rd/components.md | 25 + README.md | 1 + components/demos.tsx | 6 + content/docs/components/mech-keyboard.mdx | 100 + content/docs/components/meta.json | 1 + .../docs/components/mech-keyboard/image.png | Bin 0 -> 46972 bytes public/r/mech-keyboard-svelte.json | 21 + public/r/mech-keyboard.json | 21 + public/r/registry.json | 38 + registry.json | 1 + registry/mech-keyboard/mech-keyboard-demo.tsx | 147 ++ .../mech-keyboard/mech-keyboard-vanilla.ts | 1664 +++++++++++++++++ registry/mech-keyboard/mech-keyboard.svelte | 160 ++ registry/mech-keyboard/mech-keyboard.tsx | 231 +++ registry/mech-keyboard/registry.json | 43 + skills/23rd/SKILL.md | 6 +- skills/23rd/apis.md | 31 + skills/23rd/components.md | 25 + 20 files changed, 2556 insertions(+), 2 deletions(-) create mode 100644 content/docs/components/mech-keyboard.mdx create mode 100644 public/og/docs/components/mech-keyboard/image.png create mode 100644 public/r/mech-keyboard-svelte.json create mode 100644 public/r/mech-keyboard.json create mode 100644 registry/mech-keyboard/mech-keyboard-demo.tsx create mode 100644 registry/mech-keyboard/mech-keyboard-vanilla.ts create mode 100644 registry/mech-keyboard/mech-keyboard.svelte create mode 100644 registry/mech-keyboard/mech-keyboard.tsx create mode 100644 registry/mech-keyboard/registry.json diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index ab9e2f0..5f6abd6 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -3,7 +3,7 @@ name: 23rd description: > Install, choose, and compose 23rd shadcn/ui registry components for React and Svelte 5. Use when the user wants a UI component, background, shader, WebGL - wash, liquid metal or chrome, hero atmosphere, footer, 404, color picker, typewriter text, ASCII effect, + wash, liquid metal or chrome, hero atmosphere, footer, 404, color picker, typewriter text, keyboard shortcut recorder, ASCII effect, starfield, orb, or page-tilt from the 23rd registry (@23rd, 23rd.dev, radiumcoders/23rd.dev), or when adding a new component to this registry. Covers install commands, import paths, props, theming, and when not to use each item. @@ -76,6 +76,7 @@ npm dependencies the registry declares: | --- | --- | --- | | `gooey-color-picker` | `motion` | none | | `ring-tower` | `three` | `three` | +| `mech-keyboard` | `three` | `three` | | every other item | none | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -93,6 +94,7 @@ flowchart TD pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} pick -->|Type text out letter by letter| tw[typewriter] + pick -->|Show or record a keyboard shortcut| mk[mech-keyboard] atmo -->|Quiet wash| sg[shader-gradient] atmo -->|Cel-shaded fire from the bottom| saf[shader-anime-fire] atmo -->|Sky or rain, optional glass| ss[shader-sky] @@ -127,6 +129,7 @@ Disambiguation that agents get wrong: | Rings, bands, or a tower of screenshots that spins | `ring-tower` | `shader-metal` (no images) | | A face / orb / mascot | `live-orb` | `logo-burst` | | Typing effect, typewriter, text that types itself | `typewriter` | `ascii-logo` (a wordmark, not prose) | +| Keyboard, keycaps, record a shortcut, onboarding hotkey step | `mech-keyboard` | `typewriter` (types prose, not chords) | There is one variant enum in the whole registry: `LiveOrb` `variant` is `"white" | "black" | "webgl" | "custom"`. Nothing else has `variant`. @@ -196,6 +199,7 @@ Categories match `content/docs/components/meta.json`. | `folio` | Sections | Page leans on scroll, then springs flat | React + Svelte | | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | React + Svelte | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | React + Svelte | +| `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | React + Svelte | ## Minimal installs that must be right diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 74273d2..6674d77 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -442,6 +442,37 @@ Also exported: `DEFAULT_COLOR`, `DEFAULT_TEXT`, `DEFAULT_SPEED`, `DEFAULT_COLUMN Vanilla `TypewriterInstance`: `{ setOptions, restart, destroy }`. Not returned to app code by the React component; remount with a `key` to type again. No `ticks` or `theme` props; `color` is the only color prop and it paints the enamel. +## mech-keyboard + +```ts +type MechKeyboardProps = { + x?: number // 0 px + y?: number // 0 px, +y is down + scale?: number // 1, times the fitted size + monochrome?: boolean // false: white and grey caps, ink only + interactive?: boolean // true: window keys and clicks press keys + recorder?: boolean // true: illustrated keycaps spelling out the chord + placeholder?: string // "Press a shortcut" + highlight?: string[] // KeyboardEvent.code values to pulse + platform?: "auto" | "mac" | "pc" // "auto": bottom-row legends and labels + sound?: boolean // false: synthesized switch sounds + intro?: boolean // true: rise into place with a wave across the keys + onKey?: (code: string, pressed: boolean) => void + onShortcut?: (shortcut: MechKeyboardShortcut) => void + className?: string +} + +type MechKeyboardShortcut = { + codes: string[] // modifiers first + keys: string[] // one legend per key + label: string // "⌘⇧K" on Mac, "Ctrl+Shift+K" elsewhere +} +``` + +Also exported: `DEFAULT_PLACEHOLDER`, `buildLayout(platform)`, `formatShortcut(codes, platform)`, types `MechKeyboardKey`, `MechKeyboardPlatform`. + +Vanilla `createMechKeyboard(root, stage, options)` returns `{ setOptions, replay, destroy }`. Not returned to app code by the React component; remount with a `key` to replay the intro. + ## Events that are not props | Name | Constant | Who listens | diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index 1f16ee2..eee2f84 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -439,6 +439,31 @@ Controlled when `value` is not `undefined`. Otherwise uncontrolled, seeded from Pitfalls: the panel overflows the trigger. Do not put it in `overflow-hidden`. React also exports `parseColor`. No `className` on Svelte; use `class`. +## mech-keyboard + +Component. A 75% mechanical keyboard drawn as an isometric illustration with three.js on a transparent canvas: a milled case, a volume knob, and 82 keycaps fading from dusty blue to off-white, shaded in three flat tone bands and outlined in ink, with ink legends. The visitor's keyboard presses the matching keys (window-wide, ignoring typing in inputs), clicks press them too (modifiers latch), and the recorder above draws the chord as small illustrated keycaps, then "Shortcut saved" on release. + +Best fit: a "create a shortcut" step in onboarding, a keyboard-shortcuts page, or a hero that reacts to typing. + +Not this: typed prose (`typewriter`), a text input, or a background behind other copy. + +Deps: `three` (+ `@types/three`). Props: `x` / `y` (0, px), `scale` (1), `monochrome` (false), `interactive` (true), `recorder` (true), `placeholder` (`"Press a shortcut"`), `highlight` (codes to pulse), `platform` (`"auto"`), `sound` (false), `intro` (true), `onKey(code, pressed)`, `onShortcut(shortcut)`. + +```tsx +"use client" +import { MechKeyboard } from "@/components/ui/mech-keyboard" + +export function ShortcutStep() { + return ( +
+ console.log(s.label)} /> +
+ ) +} +``` + +Pitfalls: give the parent a height; the keyboard fits itself inside it. The pose is fixed isometric: there are no rotation, `fov`, `z`, `fade`, `variant`, `colorway`, or `theme` props. Codes are `KeyboardEvent.code` (`"MetaLeft"`, `"KeyK"`), not `key`. `onShortcut` fires on release of the whole chord with `{ codes, keys, label }`; `label` is `"⌘⇧K"` on Mac and `"Ctrl+Shift+K"` elsewhere. While the keyboard has focus it calls `preventDefault` on every key but Tab, so it can record browser shortcuts. Needs WebGL; without it only the recorder renders. The knob turns under the wheel and with volume keys; clicking it records `AudioVolumeMute`. + ## typewriter Component. A real typewriter drawn in SVG. Every key is wired to its own typebar: a letter presses its key, swings that bar up out of the basket to strike the ribbon, lands on the sheet, and the carriage steps one character left. Capitals hold shift. At the margin the return lever kicks, the platen turns the paper up a line, and the carriage slides home. With `loop`, the sheet is pulled out and a fresh one rolls in. diff --git a/README.md b/README.md index 358ca3a..d0a5ea8 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ npx skills add radiumcoders/23rd.dev --skill 23rd -g | [Shader Metal](https://23rd.dev/docs/components/shader-metal) | `@23rd/shader-metal` | Liquid chrome, gold, or copper ribbons; the key light follows the pointer | | [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back | | [Typewriter](https://23rd.dev/docs/components/typewriter) | `@23rd/typewriter` | A playable typewriter in SVG; keys press, typebars strike, the carriage steps | +| [Mech Keyboard](https://23rd.dev/docs/components/mech-keyboard) | `@23rd/mech-keyboard` | An isometric keyboard illustration that presses with yours and records shortcuts | ```tsx import { GooeyColorPicker } from "@/components/ui/gooey-color-picker" diff --git a/components/demos.tsx b/components/demos.tsx index c56ca4a..e6a1f61 100644 --- a/components/demos.tsx +++ b/components/demos.tsx @@ -60,6 +60,12 @@ export const LogoBurstDemo = lazyDemo(() => import("@/registry/logo-burst/logo-burst-demo").then((m) => m.LogoBurstDemo) ) +export const MechKeyboardDemo = lazyDemo(() => + import("@/registry/mech-keyboard/mech-keyboard-demo").then( + (m) => m.MechKeyboardDemo + ) +) + export const PhosphorScoreDemo = lazyDemo(() => import("@/registry/phosphor-score/phosphor-score-demo").then( (m) => m.PhosphorScoreDemo diff --git a/content/docs/components/mech-keyboard.mdx b/content/docs/components/mech-keyboard.mdx new file mode 100644 index 0000000..e25fb99 --- /dev/null +++ b/content/docs/components/mech-keyboard.mdx @@ -0,0 +1,100 @@ +--- +title: Mech Keyboard +description: A 75% keyboard drawn as an isometric illustration that presses along with yours and records shortcuts. +--- + +import { MechKeyboardDemo } from "@/components/demos" + +Press a few keys. It is a three.js scene drawn as an isometric illustration: a milled case around a recessed plate, a volume knob, and 82 keycaps fading from dusty blue at the back to off-white at the front, shaded in three flat tone bands and outlined in ink. Legends are printed in the same ink. Your keyboard drives the keys, and the recorder above spells out the chord, ready for a "create a shortcut" step in onboarding. + + + +## Installation + + + +Or from the public GitHub registry: + + + +## Usage + +Give the parent a size; the keyboard fits itself inside it. + + + +```tsx +"use client" + +import { MechKeyboard } from "@/components/ui/mech-keyboard" + +export function ShortcutStep() { + return ( +
+ console.log(shortcut.label)} + /> +
+ ) +} +``` +
+ +```svelte + + +
+ console.log(shortcut.label)} + /> +
+``` +
+
+ +`onShortcut` fires when every key of a chord is released. It gets `codes` (`KeyboardEvent.code`s, modifiers first), `keys` (one legend per key), and `label` (`"⌘⇧K"` on Mac, `"Ctrl+Shift+K"` elsewhere). + +Keys listen on the window, but typing in an input, textarea, or contenteditable elsewhere on the page is ignored. While the keyboard itself has focus, the browser's own handling of those keys is prevented (except Tab), so it can record combos like `⌘S`. Clicking a modifier latches it; clicking another key then fires the chord. + +Needs WebGL; without it the canvas stays empty and the recorder still works. The pose is fixed at true isometric. Move it, drop the color, or pulse the keys you want pressed: + + + + ```tsx + + + + ``` + + + ```svelte + + + + ``` + + + +`monochrome` swaps the blue caps for white and grey, leaving a pure ink drawing. The recorder draws each key of the chord as a small keycap in the same style; held keys sink into their skirts. The knob turns under the mouse wheel and with the volume keys, and clicking it records Mute. + +## Props + +| Prop | Type | Default | +| ------------- | ------------------------------------- | -------------------------- | +| `x` | `number` | `0`, px | +| `y` | `number` | `0`, px | +| `scale` | `number` | `1`, times the fitted size | +| `monochrome` | `boolean` | `false` | +| `interactive` | `boolean` | `true` | +| `recorder` | `boolean` | `true` | +| `placeholder` | `string` | `"Press a shortcut"` | +| `highlight` | `string[]` | none, codes to pulse | +| `platform` | `"auto" \| "mac" \| "pc"` | `"auto"`, bottom-row legends | +| `sound` | `boolean` | `false` | +| `intro` | `boolean` | `true` | +| `onKey` | `(code: string, pressed: boolean) => void` | none | +| `onShortcut` | `(shortcut: MechKeyboardShortcut) => void` | none | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index 9505d9c..fb6193b 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -23,6 +23,7 @@ "folio", "---Components---", "gooey-color-picker", + "mech-keyboard", "---Experiments---", "phosphor-score", "typewriter" diff --git a/public/og/docs/components/mech-keyboard/image.png b/public/og/docs/components/mech-keyboard/image.png new file mode 100644 index 0000000000000000000000000000000000000000..b991ab9a6c481de3d132e68a0f934d70aabe8a63 GIT binary patch literal 46972 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 VHy`Rfy94+K($c?GaKrZ5{|5=rF!=xg literal 0 HcmV?d00001 diff --git a/public/r/mech-keyboard-svelte.json b/public/r/mech-keyboard-svelte.json new file mode 100644 index 0000000..ebd91d4 --- /dev/null +++ b/public/r/mech-keyboard-svelte.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "mech-keyboard-svelte", + "title": "Mech Keyboard", + "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", + "dependencies": [ + "three" + ], + "devDependencies": [ + "@types/three" + ], + "files": [ + { + "path": "registry/mech-keyboard/mech-keyboard.svelte", + "content": "\n\n\n\n\n

\n {#if recorder}\n
\n
\n {#if shortcut.keys.length === 0}\n {placeholder}\n {:else}\n {#each shortcut.keys as key, i (shortcut.codes[i])}\n {#if i > 0}+{/if}\n \n {key}\n \n {/each}\n {/if}\n
\n
\n {#if phase === \"done\"}\n \n \n \n Shortcut saved\n {:else if phase === \"live\"}\n \n Recording\n {:else}\n Listening\n {/if}\n
\n
\n {/if}\n\n", + "type": "registry:file", + "target": "src/lib/components/ui/mech-keyboard.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/mech-keyboard.json b/public/r/mech-keyboard.json new file mode 100644 index 0000000..30b9e2f --- /dev/null +++ b/public/r/mech-keyboard.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "mech-keyboard", + "title": "Mech Keyboard", + "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", + "dependencies": [ + "three" + ], + "devDependencies": [ + "@types/three" + ], + "files": [ + { + "path": "registry/mech-keyboard/mech-keyboard.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 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 if (event.code === \"CapsLock\") later(() => release(\"CapsLock\"), 160)\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 let pointerCode: string | null = null\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 pointerCode = code\n press(code)\n }\n\n const onPointerUp = () => {\n if (!pointerCode) return\n const code = pointerCode\n pointerCode = null\n held.delete(code)\n sound(code, false)\n opts.onKey?.(code, false)\n 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 el.remove()\n },\n }\n}\n\nconst INK_VERTEX = /* glsl */ `\nvarying vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n`\n\n/**\n * Ink where depth jumps (silhouettes, caps over the plate) or the surface\n * turns sharply (case chamfers, cap edges). Four subpixel taps averaged,\n * so the lines come out antialiased.\n */\nconst INK_FRAGMENT = /* glsl */ `\nuniform sampler2D tColor;\nuniform sampler2D tNormal;\nuniform sampler2D tDepth;\nuniform vec2 texel;\nuniform vec3 ink;\nuniform float depthRange;\nvarying vec2 vUv;\n\nfloat edgeAt(vec2 uv) {\n float d0 = texture2D(tDepth, uv).r;\n vec3 n0 = texture2D(tNormal, uv).rgb * 2.0 - 1.0;\n float e = 0.0;\n vec2 dirs[4];\n dirs[0] = vec2(1.0, 0.0);\n dirs[1] = vec2(-1.0, 0.0);\n dirs[2] = vec2(0.0, 1.0);\n dirs[3] = vec2(0.0, -1.0);\n for (int i = 0; i < 4; i++) {\n vec2 q = uv + dirs[i] * texel * 0.85;\n float dd = abs(texture2D(tDepth, q).r - d0) * depthRange;\n vec3 n1 = texture2D(tNormal, q).rgb * 2.0 - 1.0;\n e = max(e, smoothstep(0.07, 0.14, dd));\n e = max(e, smoothstep(0.22, 0.4, 1.0 - dot(n0, n1)));\n }\n return e;\n}\n\nvoid main() {\n vec4 color = texture2D(tColor, vUv);\n vec2 s = texel * 0.25;\n float e = (\n edgeAt(vUv + vec2(-s.x, -s.y)) +\n edgeAt(vUv + vec2(s.x, -s.y)) +\n edgeAt(vUv + vec2(-s.x, s.y)) +\n edgeAt(vUv + vec2(s.x, s.y))\n ) * 0.25;\n gl_FragColor = mix(color, vec4(ink, 1.0), e);\n #include \n}\n`\n\n/** Tailwind for the recorder, shared by the React and Svelte wrappers. */\nexport const CLASSES = {\n root: \"relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n stage: \"absolute inset-0 opacity-0\",\n recorder:\n \"pointer-events-none absolute inset-x-0 top-[6%] z-10 flex flex-col items-center gap-3 px-4\",\n chips: \"flex min-h-12 flex-wrap items-center justify-center gap-2\",\n /**\n * An illustrated keycap: an ink outline around a darker skirt, with the\n * top face sitting on it. Held keys sink into the skirt.\n */\n chip: \"group/chip relative inline-flex h-12 min-w-12 flex-col rounded-[9px] border-[1.5px] border-(--ink) bg-(--chip-side) pb-[6px] font-sans transition-[padding,translate,opacity,scale] duration-200 ease-[cubic-bezier(0.3,1.5,0.5,1)] starting:-translate-y-1.5 starting:scale-90 starting:opacity-0 data-live:translate-y-[3px] data-live:pb-[3px]\",\n chipTop:\n \"flex flex-1 items-center justify-center rounded-[7px] border-b-[1.5px] border-(--ink) bg-(--chip) px-3 text-sm leading-none font-semibold text-(--ink)\",\n plus: \"text-sm font-semibold text-muted-foreground\",\n placeholder: \"text-sm text-muted-foreground\",\n status:\n \"flex h-4 items-center gap-1.5 text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase\",\n dot: \"size-1.5 animate-pulse rounded-full bg-red-500\",\n check: \"size-3\",\n}\n\nexport type MechKeyboardProps = {\n /** Horizontal offset, px. */\n x?: number\n /** Vertical offset, px. */\n y?: number\n /** Multiplies the fitted size. */\n scale?: number\n /** Ink and greys only; no blue caps. */\n monochrome?: boolean\n /** Real key presses and clicks press the keys. */\n interactive?: boolean\n /** Shows the shortcut being pressed above the keyboard. */\n recorder?: boolean\n /** Recorder text before the first key. */\n placeholder?: string\n /** `KeyboardEvent.code`s to pulse, e.g. `[\"MetaLeft\", \"KeyK\"]`. */\n highlight?: string[]\n /** Bottom-row legends. `\"auto\"` detects Mac. */\n platform?: MechKeyboardPlatform | \"auto\"\n /** Synthesized switch sounds. */\n sound?: boolean\n /** Rise into place with a wave across the keys on mount. */\n intro?: boolean\n /** Every key down and up. */\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n className?: string\n}\n\nconst noop = () => () => {}\n\nfunction Check() {\n return (\n \n \n \n )\n}\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration in\n * three.js: flat tone bands, ink outlines, a volume knob. Keys spring down\n * under your keyboard (or a click; modifiers latch), and the recorder above\n * spells out the shortcut in little illustrated keycaps, onboarding-style.\n * Honors `prefers-reduced-motion`.\n */\nexport function MechKeyboard({\n x = 0,\n y = 0,\n scale = 1,\n monochrome = false,\n interactive = true,\n recorder = true,\n placeholder = DEFAULT_PLACEHOLDER,\n highlight,\n platform = \"auto\",\n sound = false,\n intro = true,\n onKey,\n onShortcut,\n className,\n}: MechKeyboardProps) {\n const rootRef = useRef(null)\n const stageRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacks = useRef({ onKey, onShortcut })\n const detected = useSyncExternalStore(\n noop,\n detectPlatform,\n (): MechKeyboardPlatform => \"pc\"\n )\n const [chord, setChord] = useState<{\n codes: string[]\n phase: MechKeyboardChordPhase\n }>({ codes: [], phase: \"idle\" })\n\n const resolvedPlatform = platform === \"auto\" ? detected : platform\n const shortcut = formatShortcut(chord.codes, resolvedPlatform)\n const highlightKey = (highlight ?? []).join(\" \")\n\n useEffect(() => {\n callbacks.current = { onKey, onShortcut }\n }, [onKey, onShortcut])\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n if (!root || !stage) return\n instanceRef.current = createMechKeyboard(root, stage, {\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight,\n platform: resolvedPlatform,\n onKey: (code, pressed) => callbacks.current.onKey?.(code, pressed),\n onShortcut: (s) => callbacks.current.onShortcut?.(s),\n onChord: (codes, phase) => setChord({ codes, phase }),\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight: highlightKey ? highlightKey.split(\" \") : [],\n platform: resolvedPlatform,\n })\n }, [\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlightKey,\n resolvedPlatform,\n ])\n\n return (\n \n
\n {recorder ? (\n
\n
\n {shortcut.keys.length === 0 ? (\n {placeholder}\n ) : (\n shortcut.keys.map((key, i) => (\n \n {i > 0 ? + : null}\n \n {key}\n \n \n ))\n )}\n
\n
\n {chord.phase === \"done\" ? (\n <>\n \n Shortcut saved\n \n ) : chord.phase === \"live\" ? (\n <>\n \n Recording\n \n ) : (\n \"Listening\"\n )}\n
\n
\n ) : null}\n
\n )\n}\n", + "type": "registry:ui", + "target": "components/ui/mech-keyboard.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/registry.json b/public/r/registry.json index 20f9f55..15718db 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -215,6 +215,44 @@ ], "type": "registry:ui" }, + { + "name": "mech-keyboard", + "title": "Mech Keyboard", + "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", + "dependencies": [ + "three" + ], + "devDependencies": [ + "@types/three" + ], + "files": [ + { + "path": "registry/mech-keyboard/mech-keyboard.tsx", + "type": "registry:ui", + "target": "components/ui/mech-keyboard.tsx" + } + ], + "type": "registry:ui" + }, + { + "name": "mech-keyboard-svelte", + "title": "Mech Keyboard", + "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", + "dependencies": [ + "three" + ], + "devDependencies": [ + "@types/three" + ], + "files": [ + { + "path": "registry/mech-keyboard/mech-keyboard.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/mech-keyboard.svelte" + } + ], + "type": "registry:ui" + }, { "name": "phosphor-score", "title": "Phosphor Score", diff --git a/registry.json b/registry.json index f96b43a..842277c 100644 --- a/registry.json +++ b/registry.json @@ -11,6 +11,7 @@ "registry/iso-lattice/registry.json", "registry/live-orb/registry.json", "registry/logo-burst/registry.json", + "registry/mech-keyboard/registry.json", "registry/phosphor-score/registry.json", "registry/radiant-lines/registry.json", "registry/ring-tower/registry.json", diff --git a/registry/mech-keyboard/mech-keyboard-demo.tsx b/registry/mech-keyboard/mech-keyboard-demo.tsx new file mode 100644 index 0000000..91f931b --- /dev/null +++ b/registry/mech-keyboard/mech-keyboard-demo.tsx @@ -0,0 +1,147 @@ +"use client" + +import { useState } from "react" +import { RiRestartLine } from "@remixicon/react" + +import { Button } from "@/components/ui/button" +import { + ComponentControls, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { MechKeyboard } from "@/registry/mech-keyboard/mech-keyboard" + +const HINT = ["MetaLeft", "KeyK"] + +const DEFAULTS = { + x: 0, + y: 0, + scale: 1, + monochrome: false, + recorder: true, + hint: false, + sound: false, + interactive: true, +} + +const px = (v: number) => `${v}px` + +export function MechKeyboardDemo() { + const [take, setTake] = useState(0) + const { props, updateProp, resetProps, hasChanges } = + usePreviewProps(DEFAULTS) + + const changed = (key: K) => + props[key] === DEFAULTS[key] ? undefined : props[key] + + return ( + <> + +
+ +
+
+ + +
+ Intro + +
+ updateProp("x", v)} + /> + updateProp("y", v)} + /> + updateProp("scale", v)} + /> + updateProp("monochrome", v)} + /> + updateProp("recorder", v)} + /> + updateProp("hint", v)} + /> + updateProp("interactive", v)} + /> + updateProp("sound", v)} + /> +
+ + ) +} diff --git a/registry/mech-keyboard/mech-keyboard-vanilla.ts b/registry/mech-keyboard/mech-keyboard-vanilla.ts new file mode 100644 index 0000000..686b757 --- /dev/null +++ b/registry/mech-keyboard/mech-keyboard-vanilla.ts @@ -0,0 +1,1664 @@ +import { + AmbientLight, + BoxGeometry, + BufferGeometry, + CanvasTexture, + Color, + DataTexture, + DepthTexture, + DirectionalLight, + ExtrudeGeometry, + Float32BufferAttribute, + Group, + HalfFloatType, + LatheGeometry, + Mesh, + MeshBasicMaterial, + MeshNormalMaterial, + MeshToonMaterial, + NearestFilter, + NoBlending, + OrthographicCamera, + Path, + PlaneGeometry, + Raycaster, + RedFormat, + Scene, + ShaderMaterial, + Shape, + ShapeGeometry, + SRGBColorSpace, + Vector2, + Vector3, + WebGLRenderer, + WebGLRenderTarget, + type Material, + type Texture, +} from "three" + +/** + * A 75% mechanical keyboard drawn as an isometric illustration with + * three.js: a milled case around a recessed plate, a volume knob, and 82 + * keycaps, shaded in three flat tone bands and outlined in ink traced from + * depth and normals. Keys spring down under the real keyboard; chords are + * recorded for a shortcut step in onboarding. + * + * Units are keys: 1 = one 1u keycap pitch. The desk is the XZ plane, y up, + * +z toward the viewer. + */ + +export type MechKeyboardPlatform = "mac" | "pc" + +export type MechKeyboardKey = { + /** `KeyboardEvent.code` of the key, or `"Fn"` */ + id: string + /** Legend, as printed */ + label: string + /** Shifted legend, printed after the label (`"1 !"`) */ + sub?: string + /** Position from the top left of the plate and width, in key units */ + x: number + y: number + w: number + /** Row: 0 is the function row */ + row: number +} + +export type MechKeyboardShortcut = { + /** `KeyboardEvent.code`s, modifiers first */ + codes: string[] + /** Display legends, one per key: `["⌘", "⇧", "K"]` */ + keys: string[] + /** `"⌘⇧K"` on Mac, `"Ctrl+Shift+K"` elsewhere */ + label: string +} + +export type MechKeyboardChordPhase = "idle" | "live" | "done" + +export type MechKeyboardOptions = { + /** Offsets in px; +y is down. Default 0. */ + x?: number + y?: number + /** Multiplies the fitted size. Default 1. */ + scale?: number + /** Ink and greys only; no blue caps. Default false. */ + monochrome?: boolean + /** Real key presses and clicks drive the keys. Default true. */ + interactive?: boolean + /** Synthesized switch sounds. Default false. */ + sound?: boolean + /** The keyboard rises into place and a wave runs across the keys. Default true. */ + intro?: boolean + /** Codes to pulse. */ + highlight?: string[] + /** Bottom-row legends and shortcut labels. Default: detected. */ + platform?: MechKeyboardPlatform + /** Leave room at the top for the recorder. Default false. */ + recorder?: boolean + onKey?: (code: string, pressed: boolean) => void + /** Fires when every key of a chord is released. */ + onShortcut?: (shortcut: MechKeyboardShortcut) => void + /** Live chord for the recorder. */ + onChord?: (codes: string[], phase: MechKeyboardChordPhase) => void +} + +export type MechKeyboardInstance = { + setOptions: (next: Partial) => void + /** Plays the intro again. */ + replay: () => void + destroy: () => void +} + +export const DEFAULT_PLACEHOLDER = "Press a shortcut" + +/* ------------------------------------------------------------------ */ +/* Palette */ +/* ------------------------------------------------------------------ */ + +type Palette = { + case: string + plate: string + /** Caps fade from the back left to the front right. */ + capBack: string + capFront: string + knob: string + /** Recorder keycaps: top face and skirt */ + chip: string + chipSide: string +} + +/** Outlines, legends, and the knob's notch. */ +const INK = "#2B3442" +const HIGHLIGHT = "#7E9BBA" + +const FROST: Palette = { + case: "#E6E7E9", + plate: "#8E96A3", + capBack: "#AFC3D7", + capFront: "#F1F2F4", + knob: "#E2E4E7", + chip: "#E9EEF4", + chipSide: "#AFC3D7", +} + +const MONO: Palette = { + case: "#F2F2F3", + plate: "#A3A6AC", + capBack: "#FAFAFA", + capFront: "#FAFAFA", + knob: "#F2F2F3", + chip: "#FAFAFA", + chipSide: "#CDD0D5", +} + +const paletteFor = (monochrome: boolean) => (monochrome ? MONO : FROST) + +/** CSS variables the recorder keycaps read. */ +export function recorderVars(monochrome = false) { + const p = paletteFor(monochrome) + return { "--chip": p.chip, "--chip-side": p.chipSide, "--ink": INK } +} + +/* ------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------ */ + +const COLS = 16 +/** The function row, a quarter-unit gap, then five rows. */ +const LAYOUT_D = 6.25 +const KNOB_ID = "AudioVolumeMute" + +type Spec = [id: string, w: number, label?: string, sub?: string] + +const GAP_KEY = "__gap" + +const gap = (w: number): Spec => [GAP_KEY, w] +const letters = (s: string): Spec[] => s.split("").map((c) => [`Key${c}`, 1, c]) +const fkeys = (from: number): Spec[] => + [0, 1, 2, 3].map((i) => [`F${from + i}`, 1, `F${from + i}`]) + +function rows(platform: MechKeyboardPlatform): Spec[][] { + const mac = platform === "mac" + const bottom: Spec[] = mac + ? [ + ["ControlLeft", 1.25, "Control"], + ["AltLeft", 1.25, "Option"], + ["MetaLeft", 1.25, "Command"], + ["Space", 6.25, ""], + ["MetaRight", 1, "⌘"], + ["AltRight", 1, "⌥"], + ["Fn", 1, "Fn"], + ] + : [ + ["ControlLeft", 1.25, "Ctrl"], + ["MetaLeft", 1.25, "Win"], + ["AltLeft", 1.25, "Alt"], + ["Space", 6.25, ""], + ["AltRight", 1, "Alt"], + ["Fn", 1, "Fn"], + ["ControlRight", 1, "Ctrl"], + ] + return [ + [ + ["Escape", 1, "Esc"], + gap(0.25), + ...fkeys(1), + gap(0.25), + ...fkeys(5), + gap(0.25), + ...fkeys(9), + gap(0.25), + ["Delete", 1, "Del"], + ], + [ + ["Backquote", 1, "`", "~"], + ["Digit1", 1, "1", "!"], + ["Digit2", 1, "2", "@"], + ["Digit3", 1, "3", "#"], + ["Digit4", 1, "4", "$"], + ["Digit5", 1, "5", "%"], + ["Digit6", 1, "6", "^"], + ["Digit7", 1, "7", "&"], + ["Digit8", 1, "8", "*"], + ["Digit9", 1, "9", "("], + ["Digit0", 1, "0", ")"], + ["Minus", 1, "-", "_"], + ["Equal", 1, "=", "+"], + ["Backspace", 2, "Backspace"], + ["Home", 1, "Home"], + ], + [ + ["Tab", 1.5, "Tab"], + ...letters("QWERTYUIOP"), + ["BracketLeft", 1, "[", "{"], + ["BracketRight", 1, "]", "}"], + ["Backslash", 1.5, "\\", "|"], + ["PageUp", 1, "PgUp"], + ], + [ + ["CapsLock", 1.75, "Caps"], + ...letters("ASDFGHJKL"), + ["Semicolon", 1, ";", ":"], + ["Quote", 1, "'", '"'], + ["Enter", 2.25, "Enter"], + ["PageDown", 1, "PgDn"], + ], + [ + ["ShiftLeft", 2.25, "Shift"], + ...letters("ZXCVBNM"), + ["Comma", 1, ",", "<"], + ["Period", 1, ".", ">"], + ["Slash", 1, "/", "?"], + ["ShiftRight", 1.75, "Shift"], + ["ArrowUp", 1, "↑"], + ["End", 1, "End"], + ], + [ + ...bottom, + ["ArrowLeft", 1, "←"], + ["ArrowDown", 1, "↓"], + ["ArrowRight", 1, "→"], + ], + ] +} + +/** The 82 keys of a 75% board, Mac or PC bottom row. */ +export function buildLayout(platform: MechKeyboardPlatform): MechKeyboardKey[] { + const keys: MechKeyboardKey[] = [] + rows(platform).forEach((row, r) => { + const y = r === 0 ? 0 : r + 0.25 + let x = 0 + for (const [id, w, label = "", sub] of row) { + if (id !== GAP_KEY) keys.push({ id, label, sub, x, y, w, row: r }) + x += w + } + }) + return keys +} + +/* ------------------------------------------------------------------ */ +/* Codes, labels, shortcuts */ +/* ------------------------------------------------------------------ */ + +export function detectPlatform(): MechKeyboardPlatform { + if (typeof navigator === "undefined") return "pc" + const nav = navigator as Navigator & { + userAgentData?: { platform?: string } + } + const name = nav.userAgentData?.platform || nav.platform || nav.userAgent + return /mac|iphone|ipad|ipod/i.test(name) ? "mac" : "pc" +} + +const MODIFIER_ORDER: Record = { + mac: ["Control", "Alt", "Shift", "Meta", "Fn"], + pc: ["Control", "Meta", "Alt", "Shift", "Fn"], +} + +/** `"ShiftLeft"` → `"Shift"`; anything else → undefined. */ +export function modifierOf(code: string) { + if (code === "Fn") return "Fn" + const match = /^(Control|Alt|Shift|Meta|OS)(Left|Right)$/.exec(code) + if (!match) return undefined + return match[1] === "OS" ? "Meta" : match[1] +} + +const MAC_LABELS: Record = { + Control: "⌃", + Alt: "⌥", + Shift: "⇧", + Meta: "⌘", + Fn: "fn", + Escape: "esc", + Tab: "⇥", + CapsLock: "⇪", + Enter: "↩", + Backspace: "⌫", + Delete: "⌦", + Space: "Space", +} + +const PC_LABELS: Record = { + Control: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Meta: "Win", + Fn: "Fn", + Escape: "Esc", + Tab: "Tab", + CapsLock: "Caps", + Enter: "Enter", + Backspace: "Backspace", + Delete: "Del", + Space: "Space", +} + +const SHARED_LABELS: Record = { + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + PageUp: "PgUp", + PageDown: "PgDn", + Home: "Home", + End: "End", + Insert: "Ins", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + IntlBackslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", + Backquote: "`", + NumpadEnter: "Enter", + AudioVolumeMute: "Mute", + AudioVolumeUp: "Vol +", + AudioVolumeDown: "Vol −", +} + +/** One legend for a `KeyboardEvent.code`. */ +export function keyLabel(code: string, platform: MechKeyboardPlatform) { + const table = platform === "mac" ? MAC_LABELS : PC_LABELS + const name = modifierOf(code) ?? code + if (table[name]) return table[name] + if (SHARED_LABELS[name]) return SHARED_LABELS[name] + const match = /^(?:Key|Digit|Numpad)(.+)$/.exec(name) + if (match) return match[1]! + return name +} + +/** Modifiers first in platform order (one per kind), then keys as pressed. */ +export function formatShortcut( + codes: string[], + platform: MechKeyboardPlatform +): MechKeyboardShortcut { + const order = MODIFIER_ORDER[platform] + const mods = new Map() + const rest: string[] = [] + for (const code of codes) { + const mod = modifierOf(code) + if (mod) { + if (!mods.has(mod)) mods.set(mod, code) + } else if (!rest.includes(code)) { + rest.push(code) + } + } + const sorted = [ + ...order.filter((m) => mods.has(m)).map((m) => mods.get(m)!), + ...rest, + ] + const keys = sorted.map((code) => keyLabel(code, platform)) + return { + codes: sorted, + keys, + label: platform === "mac" ? keys.join("") : keys.join("+"), + } +} + +const ALIASES: Record = { + Insert: ["Delete"], + NumpadEnter: ["Enter"], + IntlBackslash: ["Backslash"], + OSLeft: ["MetaLeft"], + OSRight: ["MetaRight"], + NumpadDecimal: ["Period"], + NumpadDivide: ["Slash"], + NumpadSubtract: ["Minus"], + NumpadAdd: ["Equal"], + AudioVolumeUp: [], + AudioVolumeDown: [], +} + +/** Which keycaps a code moves. */ +function capsFor(code: string): string[] { + const pad = /^Numpad(\d)$/.exec(code) + if (pad) return [`Digit${pad[1]}`] + return ALIASES[code] ?? [code] +} + +/* ------------------------------------------------------------------ */ +/* Geometry */ +/* ------------------------------------------------------------------ */ + +const DEG = Math.PI / 180 +/** True isometric: the desk turned 45° and seen from 35.26° up. */ +const ISO_TILT = 35.26 * DEG +const ISO_TURN = 45 * DEG +/** Bezel around the plate */ +const BEZEL = 0.48 +const W = COLS + BEZEL * 2 +const D = LAYOUT_D + BEZEL * 2 +/** Case height at the front; the back rises by RISE (typing angle). */ +const CASE_H = 0.95 +const RISE = 0.4 +const TILT = Math.atan2(RISE, D) +/** Bezel top down to the top of the plate */ +const WELL = 0.5 +/** Gap around a cap in its cell, switch height under the cap, key travel */ +const GAP = 0.035 +const SWITCH = 0.12 +const TRAVEL = 0.16 +/** Keycap: height, skirt taper, top edge radius, corner radius */ +const CAP_H = 0.5 +const CAP_TAPER = 0.07 +const CAP_EDGE = 0.018 +const CAP_RADIUS = 0.1 + +function roundedRect(path: T, w: number, h: number, r: number) { + const x = -w / 2 + const y = -h / 2 + path.moveTo(x + r, y) + path.lineTo(x + w - r, y) + path.quadraticCurveTo(x + w, y, x + w, y + r) + path.lineTo(x + w, y + h - r) + path.quadraticCurveTo(x + w, y + h, x + w - r, y + h) + path.lineTo(x + r, y + h) + path.quadraticCurveTo(x, y + h, x, y + h - r) + path.lineTo(x, y + r) + path.quadraticCurveTo(x, y, x + r, y) + return path +} + +/** + * The milled frame around the well: extruded with one-segment bevels, so + * every edge is a flat chamfer the ink can trace, then sheared into a + * wedge. + */ +function caseGeometry() { + const chamfer = 0.05 + const outer = roundedRect(new Shape(), W - chamfer * 2, D - chamfer * 2, 0.16) + outer.holes.push( + roundedRect( + new Path(), + COLS + 0.12 + chamfer * 2, + LAYOUT_D + 0.12 + chamfer * 2, + 0.06 + ) + ) + const geometry = new ExtrudeGeometry(outer, { + depth: CASE_H - chamfer * 2, + bevelEnabled: true, + bevelThickness: chamfer, + bevelSize: chamfer, + bevelSegments: 1, + curveSegments: 8, + }) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, chamfer, 0) + const pos = geometry.attributes.position! + for (let i = 0; i < pos.count; i++) { + const y = pos.getY(i) + const back = Math.min(1, Math.max(0, (D / 2 - pos.getZ(i)) / D)) + pos.setY(i, y + back * RISE * Math.min(1, Math.max(0, y / CASE_H))) + } + geometry.computeVertexNormals() + return geometry +} + +/** Top of the bezel at depth z (0 is the middle). */ +const bezelTop = (z: number) => CASE_H + ((D / 2 - z) / D) * RISE + +/** Points around a rounded rectangle, back right corner first. */ +function ring(hw: number, hd: number, r: number, seg: number) { + const out: Array<[number, number]> = [] + const corners: Array<[number, number, number]> = [ + [hw - r, -(hd - r), -90], + [hw - r, hd - r, 0], + [-(hw - r), hd - r, 90], + [-(hw - r), -(hd - r), 180], + ] + for (const [cx, cz, a0] of corners) { + for (let i = 0; i <= seg; i++) { + const a = (a0 + (90 * i) / seg) * DEG + out.push([cx + Math.cos(a) * r, cz + Math.sin(a) * r]) + } + } + return out +} + +/** + * A keycap as one smooth surface: a tapered skirt and a crisp top edge. + * UVs are a projection from above over the footprint, so a canvas the size + * of the footprint prints its legend onto the top. + */ +function capGeometry(w: number) { + const fw = w - GAP * 2 + const fd = 1 - GAP * 2 + const profile: Array<[inset: number, y: number]> = [] + for (let i = 0; i <= 2; i++) { + const t = i / 2 + profile.push([CAP_TAPER * t, (CAP_H - CAP_EDGE) * t]) + } + for (let i = 1; i <= 3; i++) { + const a = (i / 3) * (Math.PI / 2) + profile.push([ + CAP_TAPER + CAP_EDGE * (1 - Math.cos(a)), + CAP_H - CAP_EDGE + CAP_EDGE * Math.sin(a), + ]) + } + + const rings: Array> = [] + for (const [inset, y] of profile) { + const r = Math.max(0.03, CAP_RADIUS - inset * 0.35) + rings.push( + ring(fw / 2 - inset, fd / 2 - inset, r, 5).map(([x, z]) => [x, y, z]) + ) + } + const top = rings[rings.length - 1]! + for (const s of [0.5]) rings.push(top.map(([x, y, z]) => [x * s, y, z * s])) + + const positions: number[] = [] + const uvs: number[] = [] + for (const points of rings) { + for (const [x, y, z] of points) { + positions.push(x, y, z) + uvs.push(0.5 + x / fw, 0.5 - z / fd) + } + } + positions.push(0, CAP_H, 0) + uvs.push(0.5, 0.5) + + const n = top.length + const index: number[] = [] + for (let i = 0; i < rings.length - 1; i++) { + for (let j = 0; j < n; j++) { + const a = i * n + j + const b = i * n + ((j + 1) % n) + const c = (i + 1) * n + j + const d = (i + 1) * n + ((j + 1) % n) + index.push(a, c, b, b, c, d) + } + } + const center = rings.length * n + const last = (rings.length - 1) * n + for (let j = 0; j < n; j++) { + index.push(last + j, center, last + ((j + 1) % n)) + } + + const geometry = new BufferGeometry() + geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)) + geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2)) + geometry.setIndex(index) + geometry.computeVertexNormals() + return geometry +} + +/** The volume knob, turned on a lathe. */ +function knobGeometry() { + const r = 0.4 + const h = 0.62 + const points = [ + new Vector2(0, 0), + new Vector2(r - 0.02, 0), + new Vector2(r, 0.02), + new Vector2(r, h - 0.03), + new Vector2(r - 0.03, h), + new Vector2(0, h), + ] + return new LatheGeometry(points, 64) +} + +/* ------------------------------------------------------------------ */ +/* Textures */ +/* ------------------------------------------------------------------ */ + +function canvas(w: number, h: number) { + const el = document.createElement("canvas") + el.width = w + el.height = h + return [el, el.getContext("2d")!] as const +} + +/** Three flat tone bands. */ +function rampTexture() { + const texture = new DataTexture( + new Uint8Array([120, 196, 255]), + 3, + 1, + RedFormat + ) + texture.minFilter = NearestFilter + texture.magFilter = NearestFilter + texture.generateMipmaps = false + texture.needsUpdate = true + return texture +} + +const LEGEND_PX = 256 + +/** Arrow legends, as the angle of a right-pointing arrow. */ +const ARROWS: Record = { + "→": 0, + "↓": Math.PI / 2, + "←": Math.PI, + "↑": -Math.PI / 2, +} + +/** The cap's color with its legend in ink, like the outlines. */ +function capTexture(key: MechKeyboardKey, color: string, font: string) { + const fw = key.w - GAP * 2 + const fd = 1 - GAP * 2 + const [el, ctx] = canvas( + Math.round(fw * LEGEND_PX), + Math.round(fd * LEGEND_PX) + ) + ctx.fillStyle = color + ctx.fillRect(0, 0, el.width, el.height) + ctx.fillStyle = INK + ctx.strokeStyle = INK + const cx = el.width / 2 + const cy = el.height * 0.47 + const arrow = ARROWS[key.label] + if (arrow !== undefined) { + // Drawn, not typed: plenty of UI fonts have no ← or →. + const len = 0.17 * LEGEND_PX + ctx.save() + ctx.translate(cx, cy) + ctx.rotate(arrow) + ctx.lineWidth = 0.02 * LEGEND_PX + ctx.lineCap = "round" + ctx.lineJoin = "round" + ctx.beginPath() + ctx.moveTo(-len / 2, 0) + ctx.lineTo(len / 2, 0) + ctx.moveTo(len / 2 - len * 0.32, -len * 0.3) + ctx.lineTo(len / 2, 0) + ctx.lineTo(len / 2 - len * 0.32, len * 0.3) + ctx.stroke() + ctx.restore() + } else { + const single = [...key.label].length === 1 && !key.sub + const text = key.sub + ? `${key.label} ${key.sub}` + : single + ? key.label + : key.label.toUpperCase() + const size = single ? 0.22 : key.sub ? 0.17 : 0.125 + ctx.font = `600 ${size * LEGEND_PX}px ${font}` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + if (!single) ctx.letterSpacing = `${0.014 * LEGEND_PX}px` + ctx.fillText(text, cx, cy) + } + const texture = new CanvasTexture(el) + texture.colorSpace = SRGBColorSpace + texture.anisotropy = 8 + return texture +} + +/* ------------------------------------------------------------------ */ +/* Sound */ +/* ------------------------------------------------------------------ */ + +function createThock() { + let audio: AudioContext | null = null + let noise: AudioBuffer | null = null + let master: GainNode | null = null + return { + play(code: string, down: boolean) { + try { + if (!audio) { + const Ctx = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext + audio = new Ctx() + master = audio.createGain() + master.gain.value = 0.55 + master.connect(audio.destination) + noise = audio.createBuffer( + 1, + Math.floor(audio.sampleRate * 0.06), + audio.sampleRate + ) + const data = noise.getChannelData(0) + for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1 + } + if (audio.state === "suspended") void audio.resume() + const t = audio.currentTime + const big = + code === "Space" || + code === "Enter" || + code === "Backspace" || + code.startsWith("Shift") + const vary = 0.93 + Math.random() * 0.14 + + const body = audio.createOscillator() + body.type = "sine" + const f = 132 * (big ? 0.72 : 1) * (down ? 1 : 1.3) * vary + body.frequency.setValueAtTime(f * 1.9, t) + body.frequency.exponentialRampToValueAtTime(f, t + 0.03) + const bodyGain = audio.createGain() + bodyGain.gain.setValueAtTime(down ? 0.32 : 0.1, t) + bodyGain.gain.exponentialRampToValueAtTime( + 0.0001, + t + (down ? 0.1 : 0.05) + ) + body.connect(bodyGain).connect(master!) + body.start(t) + body.stop(t + 0.12) + + const hit = audio.createBufferSource() + hit.buffer = noise + const band = audio.createBiquadFilter() + band.type = "bandpass" + band.frequency.value = 2900 * (down ? 1 : 1.25) * vary + band.Q.value = 1.1 + const hitGain = audio.createGain() + hitGain.gain.setValueAtTime(down ? 0.5 : 0.22, t) + hitGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.035) + hit.connect(band).connect(hitGain).connect(master!) + hit.start(t) + hit.stop(t + 0.06) + } catch { + /* no audio: stay silent */ + } + }, + close() { + void audio?.close() + }, + } +} + +function isEditable(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false + if (target.isContentEditable) return true + const tag = target.tagName + if (tag === "TEXTAREA" || tag === "SELECT") return true + if (tag !== "INPUT") return false + const type = (target as HTMLInputElement).type + return ![ + "checkbox", + "radio", + "button", + "range", + "color", + "submit", + "reset", + ].includes(type) +} + +/* ------------------------------------------------------------------ */ +/* Engine */ +/* ------------------------------------------------------------------ */ + +type Resolved = Required< + Omit +> & + Pick & { + highlight: string[] + } + +function resolve(options: MechKeyboardOptions): Resolved { + const num = (v: number | undefined, d: number) => + typeof v === "number" && Number.isFinite(v) ? v : d + return { + x: num(options.x, 0), + y: num(options.y, 0), + scale: Math.max(0.05, num(options.scale, 1)), + monochrome: options.monochrome ?? false, + interactive: options.interactive ?? true, + sound: options.sound ?? false, + intro: options.intro ?? true, + highlight: options.highlight ?? [], + platform: + options.platform === "mac" || options.platform === "pc" + ? options.platform + : detectPlatform(), + recorder: options.recorder ?? false, + onKey: options.onKey, + onShortcut: options.onShortcut, + onChord: options.onChord, + } +} + +type Cap = { + id: string + x: number + y: number + holder: Group + /** Rest height of the holder */ + base: number + /** How far it goes down */ + travel: number + material: MeshToonMaterial + pos: number + vel: number + target: number +} + +const NEAR = 0.1 +const FAR = 200 + +/** + * Mounts a canvas in `stage` and listens on `root` + * (focusable; pointer and keys). Returns a no-op instance without WebGL. + */ +export function createMechKeyboard( + root: HTMLElement, + stage: HTMLElement, + initial: MechKeyboardOptions = {} +): MechKeyboardInstance { + let opts = resolve(initial) + let renderer: WebGLRenderer + try { + renderer = new WebGLRenderer({ alpha: true, antialias: true }) + } catch { + return { setOptions() {}, replay() {}, destroy() {} } + } + renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2)) + renderer.outputColorSpace = SRGBColorSpace + renderer.setClearColor(0x000000, 0) + const el = renderer.domElement + el.style.display = "block" + el.style.width = "100%" + el.style.height = "100%" + stage.appendChild(el) + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + let reduced = mqReduce.matches + let destroyed = false + const disposables: Array = [] + const keep = (item: T) => { + disposables.push(item) + return item + } + + /* ---------- scene ---------- */ + + const scene = new Scene() + const camera = new OrthographicCamera(-1, 1, 1, -1, NEAR, FAR) + + // desk: tipped toward the camera. board: turned 45° on the desk. + const desk = new Group() + desk.rotation.x = ISO_TILT + scene.add(desk) + const board = new Group() + board.rotation.y = ISO_TURN + board.position.y = -(CASE_H + RISE / 2) / 2 + desk.add(board) + + // Light fixed to the view, the illustrator's convention: tops lightest, + // left faces middle, right faces darkest. + const key = new DirectionalLight(0xffffff, 1.9) + key.position.set(-3, 6, 4) + scene.add(key, key.target, new AmbientLight(0xffffff, 1.1)) + + // A flat, hard-edged shadow, offset down and to the right. + const shadow = new Mesh( + keep( + new ShapeGeometry(roundedRect(new Shape(), W, D, 0.16), 8).rotateX( + -Math.PI / 2 + ) + ), + keep( + new MeshBasicMaterial({ + color: INK, + transparent: true, + opacity: 0.1, + depthWrite: false, + }) + ) + ) + shadow.rotation.y = ISO_TURN + shadow.position.set(0.55, board.position.y + 0.003, 0.75) + desk.add(shadow) + + const ramp = keep(rampTexture()) + const caseGeo = keep(caseGeometry()) + const knobGeo = keep(knobGeometry()) + const capGeos = new Map() + const capGeo = (w: number) => { + let g = capGeos.get(w) + if (!g) capGeos.set(w, (g = keep(capGeometry(w)))) + return g + } + + /* ---------- the board, rebuilt per palette, platform, and font ---------- */ + + let built: { + group: Group + disposables: Array + knob: Mesh + pickable: Mesh[] + } | null = null + let builtFor = "" + const caps = new Map() + let knobAngle = 0 + + const fontFamily = () => + getComputedStyle(root).fontFamily || "ui-sans-serif, system-ui, sans-serif" + const plateX = (x: number) => -COLS / 2 + x + const plateZ = (y: number) => -LAYOUT_D / 2 + y + + const build = () => { + const font = fontFamily() + const stamp = `${opts.monochrome}|${opts.platform}|${font}` + if (stamp === builtFor) return + builtFor = stamp + const palette = paletteFor(opts.monochrome) + const own: Array = [] + const take = (item: T) => { + own.push(item) + return item + } + const toon = (color: Color | string, map: Texture | null = null) => + take( + new MeshToonMaterial({ + color, + map, + gradientMap: ramp, + emissive: new Color(HIGHLIGHT), + emissiveIntensity: 0, + }) + ) + + const group = new Group() + const caseMat = toon(palette.case) + group.add(new Mesh(caseGeo, caseMat)) + const bottom = new Mesh( + take(new BoxGeometry(W - 0.4, 0.06, D - 0.4)), + caseMat + ) + bottom.position.y = 0.03 + group.add(bottom) + + // Plate and keys ride the tipped top. + const deck = new Group() + deck.position.y = bezelTop(0) - WELL + deck.rotation.x = TILT + group.add(deck) + + const plate = new Mesh( + take(new BoxGeometry(COLS + 0.2, 0.08, LAYOUT_D + 0.2)), + toon(palette.plate) + ) + plate.position.y = -0.04 + deck.add(plate) + + const prev = new Map(caps) + caps.clear() + + const knobMat = toon(palette.knob) + const knobHolder = new Group() + knobHolder.position.set(plateX(15.5), 0.08, plateZ(0.5)) + deck.add(knobHolder) + const knob = new Mesh(knobGeo, knobMat) + knob.userData.id = KNOB_ID + knob.rotation.y = knobAngle + knobHolder.add(knob) + const notch = new Mesh( + take(new BoxGeometry(0.05, 0.012, 0.2)), + take(new MeshBasicMaterial({ color: INK })) + ) + notch.position.set(0, 0.622, -0.19) + knob.add(notch) + const pickable: Mesh[] = [knob] + + const next: Cap[] = [ + { + id: KNOB_ID, + x: 15, + y: 0, + holder: knobHolder, + base: 0.08, + travel: 0.06, + material: knobMat, + pos: 0, + vel: 0, + target: 0, + }, + ] + + const from = new Color(palette.capBack) + const to = new Color(palette.capFront) + for (const k of buildLayout(opts.platform)) { + const cx = plateX(k.x + k.w / 2) + const cz = plateZ(k.y + 0.5) + const holder = new Group() + holder.position.set(cx, SWITCH, cz) + deck.add(holder) + + // The back rows carry the color; it washes out toward the front right. + const t = Math.min( + 1, + Math.max( + 0, + (k.y / 5.25) * 0.82 + ((k.x + k.w / 2) / COLS) * 0.26 - 0.08 + ) + ) + const color = from.clone().lerp(to, t) + const map = k.label ? take(capTexture(k, color.getStyle(), font)) : null + const material = toon(map ? "#ffffff" : color, map) + const mesh = new Mesh(capGeo(k.w), material) + mesh.userData.id = k.id + holder.add(mesh) + pickable.push(mesh) + next.push({ + id: k.id, + x: k.x, + y: k.y, + holder, + base: SWITCH, + travel: TRAVEL, + material, + pos: 0, + vel: 0, + target: 0, + }) + } + + // Keys that were down stay down across a rebuild. + for (const cap of next) { + const old = prev.get(cap.id) + if (old) { + cap.pos = old.pos + cap.target = old.target + } + cap.holder.position.y = cap.base + cap.pos + caps.set(cap.id, cap) + } + + board.add(group) + if (built) { + board.remove(built.group) + for (const item of built.disposables) item.dispose() + } + built = { group, disposables: own, knob, pickable } + paintHighlights() + } + + /* ---------- ink: edges from depth and normals ---------- */ + + const color = new WebGLRenderTarget(1, 1, { type: HalfFloatType, samples: 4 }) + const normal = new WebGLRenderTarget(1, 1, { type: HalfFloatType }) + normal.depthTexture = new DepthTexture(1, 1) + const normalMat = keep(new MeshNormalMaterial()) + const quad = new Mesh( + keep(new PlaneGeometry(2, 2)), + keep( + new ShaderMaterial({ + uniforms: { + tColor: { value: color.texture }, + tNormal: { value: normal.texture }, + tDepth: { value: normal.depthTexture }, + texel: { value: new Vector2() }, + ink: { value: new Color(INK) }, + depthRange: { value: FAR - NEAR }, + }, + vertexShader: INK_VERTEX, + fragmentShader: INK_FRAGMENT, + depthTest: false, + depthWrite: false, + blending: NoBlending, + }) + ) + ) + const post = new Scene() + post.add(quad) + const postCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1) + + const render = () => { + renderer.setRenderTarget(color) + renderer.setClearColor(0x000000, 0) + renderer.render(scene, camera) + + shadow.visible = false + scene.overrideMaterial = normalMat + renderer.setRenderTarget(normal) + renderer.setClearColor(0x8080ff, 1) + renderer.render(scene, camera) + scene.overrideMaterial = null + shadow.visible = true + + renderer.setClearColor(0x000000, 0) + renderer.setRenderTarget(null) + renderer.render(post, postCamera) + } + + /* ---------- fit and offset ---------- */ + + let width = 0 + let height = 0 + const current = { x: 0, y: 0 } + let introFrom: { x: number; y: number } | null = null + let introStart = 0 + const INTRO_MS = 1300 + + const corners: Vector3[] = [] + for (const x of [-W / 2, W / 2]) + for (const y of [0, CASE_H + RISE + 0.1]) + for (const z of [-D / 2, D / 2]) corners.push(new Vector3(x, y, z)) + const scratch = new Vector3() + + /** Frame the board exactly: measure its projected box straight on. */ + const fit = () => { + if (!width || !height) return + desk.position.set(0, 0, 0) + desk.updateMatrixWorld(true) + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const c of corners) { + scratch.copy(c).applyMatrix4(board.matrixWorld) + minX = Math.min(minX, scratch.x) + maxX = Math.max(maxX, scratch.x) + minY = Math.min(minY, scratch.y) + maxY = Math.max(maxY, scratch.y) + } + const aspect = width / height + const room = opts.recorder ? 0.66 : 0.82 + const half = + Math.max((maxY - minY) / 2 / room, (maxX - minX) / 2 / 0.88 / aspect) / + opts.scale + camera.left = -half * aspect + camera.right = half * aspect + camera.top = half + camera.bottom = -half + camera.position.set((minX + maxX) / 2, (minY + maxY) / 2, FAR / 2) + camera.lookAt(camera.position.x, camera.position.y, 0) + camera.updateProjectionMatrix() + place() + } + + function place() { + const perPx = (camera.top - camera.bottom) / (height || 1) + const drop = opts.recorder ? height * 0.04 : 0 + desk.position.set(current.x * perPx, -(current.y + drop) * perPx, 0) + } + + const resize = () => { + const w = stage.clientWidth + const h = stage.clientHeight + if (!w || !h || (w === width && h === height)) return false + width = w + height = h + renderer.setSize(w, h, false) + const pr = renderer.getPixelRatio() + const pw = Math.max(1, Math.round(w * pr)) + const ph = Math.max(1, Math.round(h * pr)) + // Normals and depth at twice the resolution on 1x screens, so the + // four-tap average in the ink pass has subpixels to antialias with. + const s = pr < 2 ? 2 : 1 + color.setSize(pw, ph) + normal.setSize(pw * s, ph * s) + quad.material.uniforms.texel!.value.set(1 / pw, 1 / ph) + fit() + return true + } + + /* ---------- loop ---------- */ + + let raf = 0 + let last = 0 + let onScreen = true + let pulse = 0 + let knobGoal = 0 + + const wake = () => { + if (raf || destroyed || !onScreen || document.hidden) return + last = performance.now() + raf = requestAnimationFrame(frame) + } + + const frame = (now: number) => { + raf = 0 + if (destroyed) return + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + let busy = false + + // Offset: the intro rise, then exponential smoothing toward the props. + if (introFrom) { + const t = Math.min(1, (now - introStart) / INTRO_MS) + const e = 1 - Math.pow(1 - t, 4) + current.x = introFrom.x + (opts.x - introFrom.x) * e + current.y = introFrom.y + (opts.y - introFrom.y) * e + if (t >= 1) introFrom = null + busy = true + } else { + const a = reduced ? 1 : 1 - Math.exp(-dt * 7) + for (const k of ["x", "y"] as const) { + const delta = opts[k] - current[k] + if (Math.abs(delta) > 0.05) { + current[k] += delta * a + busy = true + } else { + current[k] = opts[k] + } + } + } + place() + + // Keys: a stiff spring down, a bouncier one back up, in fixed 4 ms + // substeps (one Euler step this stiff blows up on a slow frame). + const steps = Math.max(1, Math.ceil(dt / 0.004)) + const h = dt / steps + for (const cap of caps.values()) { + if (cap.pos === cap.target && cap.vel === 0) continue + const down = cap.target < cap.pos + const k = down ? 2600 : 900 + const c = down ? 80 : 26 + for (let i = 0; i < steps; i++) { + cap.vel += (-k * (cap.pos - cap.target) - c * cap.vel) * h + cap.pos += cap.vel * h + } + if ( + reduced || + (Math.abs(cap.pos - cap.target) < 0.0005 && Math.abs(cap.vel) < 0.01) + ) { + cap.pos = cap.target + cap.vel = 0 + } else { + busy = true + } + cap.holder.position.y = cap.base + cap.pos + } + + const turn = knobGoal - knobAngle + if (Math.abs(turn) > 0.0005) { + knobAngle += turn * (reduced ? 1 : 1 - Math.exp(-dt * 14)) + if (built) built.knob.rotation.y = knobAngle + busy = true + } + + if (opts.highlight.length) { + pulse += dt + const glow = reduced ? 0.3 : 0.2 + 0.16 * Math.sin(pulse * 4) + for (const id of opts.highlight) { + const cap = caps.get(id) + if (cap) cap.material.emissiveIntensity = glow + } + if (!reduced) busy = true + } + + render() + if (busy) wake() + } + + function paintHighlights() { + const set = new Set(opts.highlight) + for (const [id, cap] of caps) { + cap.material.emissiveIntensity = set.has(id) ? 0.3 : 0 + } + } + + /* ---------- keys ---------- */ + + const held = new Set() + const latched = new Set() + let chord: string[] = [] + const thock = createThock() + + const capFor = (id: string) => { + const cap = caps.get(id) + if (cap || !/(Left|Right)$/.test(id)) return cap + const twin = id.endsWith("Left") + ? id.replace(/Left$/, "Right") + : id.replace(/Right$/, "Left") + return caps.get(twin) + } + + const paint = () => { + const down = new Set() + for (const code of [...held, ...latched]) { + for (const id of capsFor(code)) { + const cap = capFor(id) + if (cap) down.add(cap) + } + } + for (const cap of caps.values()) { + cap.target = down.has(cap) ? -cap.travel : 0 + } + wake() + } + + const sound = (code: string, down: boolean) => { + if (opts.sound) thock.play(code, down) + } + + const press = (code: string) => { + if (held.has(code)) return + if (code === "AudioVolumeUp") knobGoal -= 0.35 + if (code === "AudioVolumeDown") knobGoal += 0.35 + if (held.size === 0 && latched.size === 0) chord = [] + held.add(code) + if (!chord.includes(code)) chord.push(code) + paint() + sound(code, true) + opts.onKey?.(code, true) + opts.onChord?.([...chord], "live") + } + + const commit = () => { + if (held.size || latched.size || chord.length === 0) return + const done = chord + chord = [] + opts.onChord?.(done, "done") + opts.onShortcut?.(formatShortcut(done, opts.platform)) + } + + const release = (code: string) => { + if (!held.delete(code)) return + paint() + sound(code, false) + opts.onKey?.(code, false) + commit() + } + + const releaseAll = () => { + const codes = [...held] + held.clear() + latched.clear() + paint() + for (const code of codes) opts.onKey?.(code, false) + commit() + } + + const timers = new Set>() + const later = (fn: () => void, ms: number) => { + const id = setTimeout(() => { + timers.delete(id) + if (!destroyed) fn() + }, ms) + timers.add(id) + } + + const focused = () => + document.activeElement === root || root.contains(document.activeElement) + + const onKeyDown = (event: KeyboardEvent) => { + if (!opts.interactive || !event.code) return + if (isEditable(event.target) && !root.contains(event.target as Node)) return + if (focused() && event.code !== "Tab") event.preventDefault() + if (event.repeat) return + press(event.code) + // macOS fires one event per Caps Lock toggle; never leave it stuck. + if (event.code === "CapsLock") later(() => release("CapsLock"), 160) + } + + const onKeyUp = (event: KeyboardEvent) => { + if (!event.code) return + if (event.code === "CapsLock" && !held.has("CapsLock")) { + if (!opts.interactive) return + press("CapsLock") + later(() => release("CapsLock"), 120) + return + } + // While ⌘ is held macOS swallows other keyups; release them with it. + if (modifierOf(event.code) === "Meta") { + for (const code of [...held]) if (!modifierOf(code)) release(code) + } + release(event.code) + } + + /* Pointer: modifiers latch, a non-modifier fires the chord. */ + const raycaster = new Raycaster() + const ndc = new Vector2() + const pick = (event: { clientX: number; clientY: number }) => { + if (!built) return null + const r = el.getBoundingClientRect() + if (!r.width || !r.height) return null + ndc.set( + ((event.clientX - r.left) / r.width) * 2 - 1, + -((event.clientY - r.top) / r.height) * 2 + 1 + ) + raycaster.setFromCamera(ndc, camera) + const hit = raycaster.intersectObjects(built.pickable, false)[0] + return (hit?.object.userData.id as string | undefined) ?? null + } + + let pointerCode: string | null = null + + const onPointerDown = (event: PointerEvent) => { + if (!opts.interactive || event.button !== 0) return + const code = pick(event) + if (!code) return + event.preventDefault() + root.focus({ preventScroll: true }) + if (modifierOf(code)) { + if (latched.has(code)) { + latched.delete(code) + chord = chord.filter((c) => c !== code) + paint() + opts.onChord?.([...chord], chord.length ? "live" : "idle") + } else { + if (held.size === 0 && latched.size === 0) chord = [] + latched.add(code) + if (!chord.includes(code)) chord.push(code) + paint() + sound(code, true) + opts.onChord?.([...chord], "live") + } + return + } + pointerCode = code + press(code) + } + + const onPointerUp = () => { + if (!pointerCode) return + const code = pointerCode + pointerCode = null + held.delete(code) + sound(code, false) + opts.onKey?.(code, false) + latched.clear() + paint() + commit() + } + + const onPointerMove = (event: PointerEvent) => { + if (!opts.interactive || event.pointerType !== "mouse") return + el.style.cursor = pick(event) ? "pointer" : "" + } + + /* The knob turns under the wheel, like the real one. */ + const onWheel = (event: WheelEvent) => { + if (!opts.interactive || pick(event) !== KNOB_ID) return + event.preventDefault() + knobGoal += Math.sign(event.deltaY) * 0.26 + wake() + } + + const onBlur = () => { + if (held.size || latched.size) releaseAll() + } + + window.addEventListener("keydown", onKeyDown) + window.addEventListener("keyup", onKeyUp) + window.addEventListener("blur", onBlur) + root.addEventListener("pointerdown", onPointerDown) + root.addEventListener("pointermove", onPointerMove) + root.addEventListener("wheel", onWheel, { passive: false }) + window.addEventListener("pointerup", onPointerUp) + window.addEventListener("pointercancel", onPointerUp) + + /* ---------- lifecycle ---------- */ + + const ro = new ResizeObserver(() => { + if (!resize() || destroyed) return + // Resizing cleared the canvas; draw the current frame now. + render() + wake() + }) + ro.observe(stage) + const io = new IntersectionObserver(([entry]) => { + onScreen = entry?.isIntersecting ?? true + wake() + }) + io.observe(stage) + const onVisibility = () => wake() + document.addEventListener("visibilitychange", onVisibility) + const onReduce = () => { + reduced = mqReduce.matches + wake() + } + mqReduce.addEventListener("change", onReduce) + + // Legends are printed from a canvas; print them again once fonts land. + void document.fonts?.ready.then(() => { + if (destroyed) return + builtFor = "" + build() + wake() + }) + + const wave = () => { + for (const cap of caps.values()) { + const delay = 380 + cap.x * 36 + cap.y * 24 + later(() => { + if (cap.target !== 0) return + cap.target = -cap.travel * 0.8 + wake() + later(paint, 110) + }, delay) + } + } + + const intro = () => { + stage.style.transition = "opacity 800ms ease-out" + if (!opts.intro || reduced) { + current.x = opts.x + current.y = opts.y + stage.style.opacity = "1" + wake() + return + } + introFrom = { x: opts.x, y: opts.y + 60 } + Object.assign(current, introFrom) + introStart = performance.now() + stage.style.opacity = "0" + requestAnimationFrame(() => { + if (!destroyed) stage.style.opacity = "1" + }) + wave() + wake() + } + + build() + resize() + intro() + + return { + setOptions(next) { + opts = resolve({ ...opts, ...next }) + if (!opts.interactive && (held.size || latched.size)) releaseAll() + build() + fit() + paintHighlights() + wake() + }, + replay() { + intro() + }, + destroy() { + destroyed = true + cancelAnimationFrame(raf) + for (const id of timers) clearTimeout(id) + timers.clear() + ro.disconnect() + io.disconnect() + document.removeEventListener("visibilitychange", onVisibility) + mqReduce.removeEventListener("change", onReduce) + window.removeEventListener("keydown", onKeyDown) + window.removeEventListener("keyup", onKeyUp) + window.removeEventListener("blur", onBlur) + root.removeEventListener("pointerdown", onPointerDown) + root.removeEventListener("pointermove", onPointerMove) + root.removeEventListener("wheel", onWheel) + window.removeEventListener("pointerup", onPointerUp) + window.removeEventListener("pointercancel", onPointerUp) + thock.close() + if (built) for (const item of built.disposables) item.dispose() + for (const item of disposables) item.dispose() + color.dispose() + normal.depthTexture?.dispose() + normal.dispose() + renderer.dispose() + el.remove() + }, + } +} + +const INK_VERTEX = /* glsl */ ` +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); +} +` + +/** + * Ink where depth jumps (silhouettes, caps over the plate) or the surface + * turns sharply (case chamfers, cap edges). Four subpixel taps averaged, + * so the lines come out antialiased. + */ +const INK_FRAGMENT = /* glsl */ ` +uniform sampler2D tColor; +uniform sampler2D tNormal; +uniform sampler2D tDepth; +uniform vec2 texel; +uniform vec3 ink; +uniform float depthRange; +varying vec2 vUv; + +float edgeAt(vec2 uv) { + float d0 = texture2D(tDepth, uv).r; + vec3 n0 = texture2D(tNormal, uv).rgb * 2.0 - 1.0; + float e = 0.0; + vec2 dirs[4]; + dirs[0] = vec2(1.0, 0.0); + dirs[1] = vec2(-1.0, 0.0); + dirs[2] = vec2(0.0, 1.0); + dirs[3] = vec2(0.0, -1.0); + for (int i = 0; i < 4; i++) { + vec2 q = uv + dirs[i] * texel * 0.85; + float dd = abs(texture2D(tDepth, q).r - d0) * depthRange; + vec3 n1 = texture2D(tNormal, q).rgb * 2.0 - 1.0; + e = max(e, smoothstep(0.07, 0.14, dd)); + e = max(e, smoothstep(0.22, 0.4, 1.0 - dot(n0, n1))); + } + return e; +} + +void main() { + vec4 color = texture2D(tColor, vUv); + vec2 s = texel * 0.25; + float e = ( + edgeAt(vUv + vec2(-s.x, -s.y)) + + edgeAt(vUv + vec2(s.x, -s.y)) + + edgeAt(vUv + vec2(-s.x, s.y)) + + edgeAt(vUv + vec2(s.x, s.y)) + ) * 0.25; + gl_FragColor = mix(color, vec4(ink, 1.0), e); + #include +} +` + +/** Tailwind for the recorder, shared by the React and Svelte wrappers. */ +export const CLASSES = { + root: "relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset", + stage: "absolute inset-0 opacity-0", + recorder: + "pointer-events-none absolute inset-x-0 top-[6%] z-10 flex flex-col items-center gap-3 px-4", + chips: "flex min-h-12 flex-wrap items-center justify-center gap-2", + /** + * An illustrated keycap: an ink outline around a darker skirt, with the + * top face sitting on it. Held keys sink into the skirt. + */ + 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]", + chipTop: + "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)", + plus: "text-sm font-semibold text-muted-foreground", + placeholder: "text-sm text-muted-foreground", + status: + "flex h-4 items-center gap-1.5 text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase", + dot: "size-1.5 animate-pulse rounded-full bg-red-500", + check: "size-3", +} diff --git a/registry/mech-keyboard/mech-keyboard.svelte b/registry/mech-keyboard/mech-keyboard.svelte new file mode 100644 index 0000000..ce2bf37 --- /dev/null +++ b/registry/mech-keyboard/mech-keyboard.svelte @@ -0,0 +1,160 @@ + + + + +
+
+ {#if recorder} +
+
+ {#if shortcut.keys.length === 0} + {placeholder} + {:else} + {#each shortcut.keys as key, i (shortcut.codes[i])} + {#if i > 0}+{/if} + + {key} + + {/each} + {/if} +
+
+ {#if phase === "done"} + + Shortcut saved + {:else if phase === "live"} + + Recording + {:else} + Listening + {/if} +
+
+ {/if} +
diff --git a/registry/mech-keyboard/mech-keyboard.tsx b/registry/mech-keyboard/mech-keyboard.tsx new file mode 100644 index 0000000..bd3a442 --- /dev/null +++ b/registry/mech-keyboard/mech-keyboard.tsx @@ -0,0 +1,231 @@ +"use client" + +import { + Fragment, + useEffect, + useRef, + useState, + useSyncExternalStore, + type CSSProperties, +} from "react" + +import { cn } from "@/lib/utils" + +import { + CLASSES, + createMechKeyboard, + DEFAULT_PLACEHOLDER, + detectPlatform, + formatShortcut, + recorderVars, + type MechKeyboardChordPhase, + type MechKeyboardInstance, + type MechKeyboardPlatform, + type MechKeyboardShortcut, +} from "./mech-keyboard-vanilla" + +export { + buildLayout, + DEFAULT_PLACEHOLDER, + formatShortcut, +} from "./mech-keyboard-vanilla" +export type { + MechKeyboardKey, + MechKeyboardPlatform, + MechKeyboardShortcut, +} from "./mech-keyboard-vanilla" + +export type MechKeyboardProps = { + /** Horizontal offset, px. */ + x?: number + /** Vertical offset, px. */ + y?: number + /** Multiplies the fitted size. */ + scale?: number + /** Ink and greys only; no blue caps. */ + monochrome?: boolean + /** Real key presses and clicks press the keys. */ + interactive?: boolean + /** Shows the shortcut being pressed above the keyboard. */ + recorder?: boolean + /** Recorder text before the first key. */ + placeholder?: string + /** `KeyboardEvent.code`s to pulse, e.g. `["MetaLeft", "KeyK"]`. */ + highlight?: string[] + /** Bottom-row legends. `"auto"` detects Mac. */ + platform?: MechKeyboardPlatform | "auto" + /** Synthesized switch sounds. */ + sound?: boolean + /** Rise into place with a wave across the keys on mount. */ + intro?: boolean + /** Every key down and up. */ + onKey?: (code: string, pressed: boolean) => void + /** Fires when every key of a chord is released. */ + onShortcut?: (shortcut: MechKeyboardShortcut) => void + className?: string +} + +const noop = () => () => {} + +function Check() { + return ( + + + + ) +} + +/** + * A 75% mechanical keyboard drawn as an isometric illustration in + * three.js: flat tone bands, ink outlines, a volume knob. Keys spring down + * under your keyboard (or a click; modifiers latch), and the recorder above + * spells out the shortcut in little illustrated keycaps, onboarding-style. + * Honors `prefers-reduced-motion`. + */ +export function MechKeyboard({ + x = 0, + y = 0, + scale = 1, + monochrome = false, + interactive = true, + recorder = true, + placeholder = DEFAULT_PLACEHOLDER, + highlight, + platform = "auto", + sound = false, + intro = true, + onKey, + onShortcut, + className, +}: MechKeyboardProps) { + const rootRef = useRef(null) + const stageRef = useRef(null) + const instanceRef = useRef(null) + const callbacks = useRef({ onKey, onShortcut }) + const detected = useSyncExternalStore( + noop, + detectPlatform, + (): MechKeyboardPlatform => "pc" + ) + const [chord, setChord] = useState<{ + codes: string[] + phase: MechKeyboardChordPhase + }>({ codes: [], phase: "idle" }) + + const resolvedPlatform = platform === "auto" ? detected : platform + const shortcut = formatShortcut(chord.codes, resolvedPlatform) + const highlightKey = (highlight ?? []).join(" ") + + useEffect(() => { + callbacks.current = { onKey, onShortcut } + }, [onKey, onShortcut]) + + useEffect(() => { + const root = rootRef.current + const stage = stageRef.current + if (!root || !stage) return + instanceRef.current = createMechKeyboard(root, stage, { + x, + y, + scale, + monochrome, + interactive, + sound, + intro, + recorder, + highlight, + platform: resolvedPlatform, + onKey: (code, pressed) => callbacks.current.onKey?.(code, pressed), + onShortcut: (s) => callbacks.current.onShortcut?.(s), + onChord: (codes, phase) => setChord({ codes, phase }), + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null + } + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + instanceRef.current?.setOptions({ + x, + y, + scale, + monochrome, + interactive, + sound, + intro, + recorder, + highlight: highlightKey ? highlightKey.split(" ") : [], + platform: resolvedPlatform, + }) + }, [ + x, + y, + scale, + monochrome, + interactive, + sound, + intro, + recorder, + highlightKey, + resolvedPlatform, + ]) + + return ( +
+
+ {recorder ? ( +
+
+ {shortcut.keys.length === 0 ? ( + {placeholder} + ) : ( + shortcut.keys.map((key, i) => ( + + {i > 0 ? + : null} + + {key} + + + )) + )} +
+
+ {chord.phase === "done" ? ( + <> + + Shortcut saved + + ) : chord.phase === "live" ? ( + <> + + Recording + + ) : ( + "Listening" + )} +
+
+ ) : null} +
+ ) +} diff --git a/registry/mech-keyboard/registry.json b/registry/mech-keyboard/registry.json new file mode 100644 index 0000000..71e3338 --- /dev/null +++ b/registry/mech-keyboard/registry.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "mech-keyboard", + "type": "registry:ui", + "title": "Mech Keyboard", + "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", + "dependencies": [ + "three" + ], + "devDependencies": [ + "@types/three" + ], + "files": [ + { + "path": "mech-keyboard.tsx", + "type": "registry:ui", + "target": "components/ui/mech-keyboard.tsx" + } + ] + }, + { + "name": "mech-keyboard-svelte", + "type": "registry:ui", + "title": "Mech Keyboard", + "description": "A 75% mechanical keyboard drawn as an isometric illustration in three.js, with ink outlines and a volume knob. Keys press along with your real keyboard, and a recorder spells out the shortcut for onboarding flows.", + "dependencies": [ + "three" + ], + "devDependencies": [ + "@types/three" + ], + "files": [ + { + "path": "mech-keyboard.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/mech-keyboard.svelte" + } + ] + } + ] +} diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index ab9e2f0..5f6abd6 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -3,7 +3,7 @@ name: 23rd description: > Install, choose, and compose 23rd shadcn/ui registry components for React and Svelte 5. Use when the user wants a UI component, background, shader, WebGL - wash, liquid metal or chrome, hero atmosphere, footer, 404, color picker, typewriter text, ASCII effect, + wash, liquid metal or chrome, hero atmosphere, footer, 404, color picker, typewriter text, keyboard shortcut recorder, ASCII effect, starfield, orb, or page-tilt from the 23rd registry (@23rd, 23rd.dev, radiumcoders/23rd.dev), or when adding a new component to this registry. Covers install commands, import paths, props, theming, and when not to use each item. @@ -76,6 +76,7 @@ npm dependencies the registry declares: | --- | --- | --- | | `gooey-color-picker` | `motion` | none | | `ring-tower` | `three` | `three` | +| `mech-keyboard` | `three` | `three` | | every other item | none | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -93,6 +94,7 @@ flowchart TD pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} pick -->|Type text out letter by letter| tw[typewriter] + pick -->|Show or record a keyboard shortcut| mk[mech-keyboard] atmo -->|Quiet wash| sg[shader-gradient] atmo -->|Cel-shaded fire from the bottom| saf[shader-anime-fire] atmo -->|Sky or rain, optional glass| ss[shader-sky] @@ -127,6 +129,7 @@ Disambiguation that agents get wrong: | Rings, bands, or a tower of screenshots that spins | `ring-tower` | `shader-metal` (no images) | | A face / orb / mascot | `live-orb` | `logo-burst` | | Typing effect, typewriter, text that types itself | `typewriter` | `ascii-logo` (a wordmark, not prose) | +| Keyboard, keycaps, record a shortcut, onboarding hotkey step | `mech-keyboard` | `typewriter` (types prose, not chords) | There is one variant enum in the whole registry: `LiveOrb` `variant` is `"white" | "black" | "webgl" | "custom"`. Nothing else has `variant`. @@ -196,6 +199,7 @@ Categories match `content/docs/components/meta.json`. | `folio` | Sections | Page leans on scroll, then springs flat | React + Svelte | | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | React + Svelte | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | React + Svelte | +| `mech-keyboard` | Components | Isometric 75% keyboard illustration; presses with yours and records shortcuts | React + Svelte | ## Minimal installs that must be right diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 74273d2..6674d77 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -442,6 +442,37 @@ Also exported: `DEFAULT_COLOR`, `DEFAULT_TEXT`, `DEFAULT_SPEED`, `DEFAULT_COLUMN Vanilla `TypewriterInstance`: `{ setOptions, restart, destroy }`. Not returned to app code by the React component; remount with a `key` to type again. No `ticks` or `theme` props; `color` is the only color prop and it paints the enamel. +## mech-keyboard + +```ts +type MechKeyboardProps = { + x?: number // 0 px + y?: number // 0 px, +y is down + scale?: number // 1, times the fitted size + monochrome?: boolean // false: white and grey caps, ink only + interactive?: boolean // true: window keys and clicks press keys + recorder?: boolean // true: illustrated keycaps spelling out the chord + placeholder?: string // "Press a shortcut" + highlight?: string[] // KeyboardEvent.code values to pulse + platform?: "auto" | "mac" | "pc" // "auto": bottom-row legends and labels + sound?: boolean // false: synthesized switch sounds + intro?: boolean // true: rise into place with a wave across the keys + onKey?: (code: string, pressed: boolean) => void + onShortcut?: (shortcut: MechKeyboardShortcut) => void + className?: string +} + +type MechKeyboardShortcut = { + codes: string[] // modifiers first + keys: string[] // one legend per key + label: string // "⌘⇧K" on Mac, "Ctrl+Shift+K" elsewhere +} +``` + +Also exported: `DEFAULT_PLACEHOLDER`, `buildLayout(platform)`, `formatShortcut(codes, platform)`, types `MechKeyboardKey`, `MechKeyboardPlatform`. + +Vanilla `createMechKeyboard(root, stage, options)` returns `{ setOptions, replay, destroy }`. Not returned to app code by the React component; remount with a `key` to replay the intro. + ## Events that are not props | Name | Constant | Who listens | diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 1f16ee2..eee2f84 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -439,6 +439,31 @@ Controlled when `value` is not `undefined`. Otherwise uncontrolled, seeded from Pitfalls: the panel overflows the trigger. Do not put it in `overflow-hidden`. React also exports `parseColor`. No `className` on Svelte; use `class`. +## mech-keyboard + +Component. A 75% mechanical keyboard drawn as an isometric illustration with three.js on a transparent canvas: a milled case, a volume knob, and 82 keycaps fading from dusty blue to off-white, shaded in three flat tone bands and outlined in ink, with ink legends. The visitor's keyboard presses the matching keys (window-wide, ignoring typing in inputs), clicks press them too (modifiers latch), and the recorder above draws the chord as small illustrated keycaps, then "Shortcut saved" on release. + +Best fit: a "create a shortcut" step in onboarding, a keyboard-shortcuts page, or a hero that reacts to typing. + +Not this: typed prose (`typewriter`), a text input, or a background behind other copy. + +Deps: `three` (+ `@types/three`). Props: `x` / `y` (0, px), `scale` (1), `monochrome` (false), `interactive` (true), `recorder` (true), `placeholder` (`"Press a shortcut"`), `highlight` (codes to pulse), `platform` (`"auto"`), `sound` (false), `intro` (true), `onKey(code, pressed)`, `onShortcut(shortcut)`. + +```tsx +"use client" +import { MechKeyboard } from "@/components/ui/mech-keyboard" + +export function ShortcutStep() { + return ( +
+ console.log(s.label)} /> +
+ ) +} +``` + +Pitfalls: give the parent a height; the keyboard fits itself inside it. The pose is fixed isometric: there are no rotation, `fov`, `z`, `fade`, `variant`, `colorway`, or `theme` props. Codes are `KeyboardEvent.code` (`"MetaLeft"`, `"KeyK"`), not `key`. `onShortcut` fires on release of the whole chord with `{ codes, keys, label }`; `label` is `"⌘⇧K"` on Mac and `"Ctrl+Shift+K"` elsewhere. While the keyboard has focus it calls `preventDefault` on every key but Tab, so it can record browser shortcuts. Needs WebGL; without it only the recorder renders. The knob turns under the wheel and with volume keys; clicking it records `AudioVolumeMute`. + ## typewriter Component. A real typewriter drawn in SVG. Every key is wired to its own typebar: a letter presses its key, swings that bar up out of the basket to strike the ribbon, lands on the sheet, and the carriage steps one character left. Capitals hold shift. At the margin the return lever kicks, the platen turns the paper up a line, and the carriage slides home. With `loop`, the sheet is pulled out and a fresh one rolls in. From 1482b8905e79824aeef5e13dbfc01d8f2d2c454a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 20:19:44 +0530 Subject: [PATCH 2/2] Release keys per pointer, hold Caps Lock off macOS, and free the WebGL context. Two fingers on two caps no longer leave one stuck down. The Caps Lock auto-release only runs where macOS sends a single event per toggle. And destroy() gives the context back instead of waiting for garbage collection. Co-Authored-By: Claude Opus 5.5 --- public/r/mech-keyboard-svelte.json | 2 +- public/r/mech-keyboard.json | 2 +- .../mech-keyboard/mech-keyboard-vanilla.ts | 36 +++++++++++++------ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/public/r/mech-keyboard-svelte.json b/public/r/mech-keyboard-svelte.json index ebd91d4..5b43a0f 100644 --- a/public/r/mech-keyboard-svelte.json +++ b/public/r/mech-keyboard-svelte.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/mech-keyboard/mech-keyboard.svelte", - "content": "\n\n\n\n\n
\n {#if recorder}\n
\n
\n {#if shortcut.keys.length === 0}\n {placeholder}\n {:else}\n {#each shortcut.keys as key, i (shortcut.codes[i])}\n {#if i > 0}+{/if}\n \n {key}\n \n {/each}\n {/if}\n
\n
\n {#if phase === \"done\"}\n \n \n \n Shortcut saved\n {:else if phase === \"live\"}\n \n Recording\n {:else}\n Listening\n {/if}\n
\n
\n {/if}\n
\n", + "content": "\n\n\n\n\n
\n {#if recorder}\n
\n
\n {#if shortcut.keys.length === 0}\n {placeholder}\n {:else}\n {#each shortcut.keys as key, i (shortcut.codes[i])}\n {#if i > 0}+{/if}\n \n {key}\n \n {/each}\n {/if}\n
\n
\n {#if phase === \"done\"}\n \n \n \n Shortcut saved\n {:else if phase === \"live\"}\n \n Recording\n {:else}\n Listening\n {/if}\n
\n
\n {/if}\n\n", "type": "registry:file", "target": "src/lib/components/ui/mech-keyboard.svelte" } diff --git a/public/r/mech-keyboard.json b/public/r/mech-keyboard.json index 30b9e2f..4ec8326 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 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 if (event.code === \"CapsLock\") later(() => release(\"CapsLock\"), 160)\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 let pointerCode: string | null = null\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 pointerCode = code\n press(code)\n }\n\n const onPointerUp = () => {\n if (!pointerCode) return\n const code = pointerCode\n pointerCode = null\n held.delete(code)\n sound(code, false)\n opts.onKey?.(code, false)\n 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 el.remove()\n },\n }\n}\n\nconst INK_VERTEX = /* glsl */ `\nvarying vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n`\n\n/**\n * Ink where depth jumps (silhouettes, caps over the plate) or the surface\n * turns sharply (case chamfers, cap edges). Four subpixel taps averaged,\n * so the lines come out antialiased.\n */\nconst INK_FRAGMENT = /* glsl */ `\nuniform sampler2D tColor;\nuniform sampler2D tNormal;\nuniform sampler2D tDepth;\nuniform vec2 texel;\nuniform vec3 ink;\nuniform float depthRange;\nvarying vec2 vUv;\n\nfloat edgeAt(vec2 uv) {\n float d0 = texture2D(tDepth, uv).r;\n vec3 n0 = texture2D(tNormal, uv).rgb * 2.0 - 1.0;\n float e = 0.0;\n vec2 dirs[4];\n dirs[0] = vec2(1.0, 0.0);\n dirs[1] = vec2(-1.0, 0.0);\n dirs[2] = vec2(0.0, 1.0);\n dirs[3] = vec2(0.0, -1.0);\n for (int i = 0; i < 4; i++) {\n vec2 q = uv + dirs[i] * texel * 0.85;\n float dd = abs(texture2D(tDepth, q).r - d0) * depthRange;\n vec3 n1 = texture2D(tNormal, q).rgb * 2.0 - 1.0;\n e = max(e, smoothstep(0.07, 0.14, dd));\n e = max(e, smoothstep(0.22, 0.4, 1.0 - dot(n0, n1)));\n }\n return e;\n}\n\nvoid main() {\n vec4 color = texture2D(tColor, vUv);\n vec2 s = texel * 0.25;\n float e = (\n edgeAt(vUv + vec2(-s.x, -s.y)) +\n edgeAt(vUv + vec2(s.x, -s.y)) +\n edgeAt(vUv + vec2(-s.x, s.y)) +\n edgeAt(vUv + vec2(s.x, s.y))\n ) * 0.25;\n gl_FragColor = mix(color, vec4(ink, 1.0), e);\n #include \n}\n`\n\n/** Tailwind for the recorder, shared by the React and Svelte wrappers. */\nexport const CLASSES = {\n root: \"relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n stage: \"absolute inset-0 opacity-0\",\n recorder:\n \"pointer-events-none absolute inset-x-0 top-[6%] z-10 flex flex-col items-center gap-3 px-4\",\n chips: \"flex min-h-12 flex-wrap items-center justify-center gap-2\",\n /**\n * An illustrated keycap: an ink outline around a darker skirt, with the\n * top face sitting on it. Held keys sink into the skirt.\n */\n chip: \"group/chip relative inline-flex h-12 min-w-12 flex-col rounded-[9px] border-[1.5px] border-(--ink) bg-(--chip-side) pb-[6px] font-sans transition-[padding,translate,opacity,scale] duration-200 ease-[cubic-bezier(0.3,1.5,0.5,1)] starting:-translate-y-1.5 starting:scale-90 starting:opacity-0 data-live:translate-y-[3px] data-live:pb-[3px]\",\n chipTop:\n \"flex flex-1 items-center justify-center rounded-[7px] border-b-[1.5px] border-(--ink) bg-(--chip) px-3 text-sm leading-none font-semibold text-(--ink)\",\n plus: \"text-sm font-semibold text-muted-foreground\",\n placeholder: \"text-sm text-muted-foreground\",\n status:\n \"flex h-4 items-center gap-1.5 text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase\",\n dot: \"size-1.5 animate-pulse rounded-full bg-red-500\",\n check: \"size-3\",\n}\n\nexport type MechKeyboardProps = {\n /** Horizontal offset, px. */\n x?: number\n /** Vertical offset, px. */\n y?: number\n /** Multiplies the fitted size. */\n scale?: number\n /** Ink and greys only; no blue caps. */\n monochrome?: boolean\n /** Real key presses and clicks press the keys. */\n interactive?: boolean\n /** Shows the shortcut being pressed above the keyboard. */\n recorder?: boolean\n /** Recorder text before the first key. */\n placeholder?: string\n /** `KeyboardEvent.code`s to pulse, e.g. `[\"MetaLeft\", \"KeyK\"]`. */\n highlight?: string[]\n /** Bottom-row legends. `\"auto\"` detects Mac. */\n platform?: MechKeyboardPlatform | \"auto\"\n /** Synthesized switch sounds. */\n sound?: boolean\n /** Rise into place with a wave across the keys on mount. */\n intro?: boolean\n /** Every key down and up. */\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n className?: string\n}\n\nconst noop = () => () => {}\n\nfunction Check() {\n return (\n \n \n \n )\n}\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration in\n * three.js: flat tone bands, ink outlines, a volume knob. Keys spring down\n * under your keyboard (or a click; modifiers latch), and the recorder above\n * spells out the shortcut in little illustrated keycaps, onboarding-style.\n * Honors `prefers-reduced-motion`.\n */\nexport function MechKeyboard({\n x = 0,\n y = 0,\n scale = 1,\n monochrome = false,\n interactive = true,\n recorder = true,\n placeholder = DEFAULT_PLACEHOLDER,\n highlight,\n platform = \"auto\",\n sound = false,\n intro = true,\n onKey,\n onShortcut,\n className,\n}: MechKeyboardProps) {\n const rootRef = useRef(null)\n const stageRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacks = useRef({ onKey, onShortcut })\n const detected = useSyncExternalStore(\n noop,\n detectPlatform,\n (): MechKeyboardPlatform => \"pc\"\n )\n const [chord, setChord] = useState<{\n codes: string[]\n phase: MechKeyboardChordPhase\n }>({ codes: [], phase: \"idle\" })\n\n const resolvedPlatform = platform === \"auto\" ? detected : platform\n const shortcut = formatShortcut(chord.codes, resolvedPlatform)\n const highlightKey = (highlight ?? []).join(\" \")\n\n useEffect(() => {\n callbacks.current = { onKey, onShortcut }\n }, [onKey, onShortcut])\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n if (!root || !stage) return\n instanceRef.current = createMechKeyboard(root, stage, {\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight,\n platform: resolvedPlatform,\n onKey: (code, pressed) => callbacks.current.onKey?.(code, pressed),\n onShortcut: (s) => callbacks.current.onShortcut?.(s),\n onChord: (codes, phase) => setChord({ codes, phase }),\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight: highlightKey ? highlightKey.split(\" \") : [],\n platform: resolvedPlatform,\n })\n }, [\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlightKey,\n resolvedPlatform,\n ])\n\n return (\n \n
\n {recorder ? (\n
\n
\n {shortcut.keys.length === 0 ? (\n {placeholder}\n ) : (\n shortcut.keys.map((key, i) => (\n \n {i > 0 ? + : null}\n \n {key}\n \n \n ))\n )}\n
\n
\n {chord.phase === \"done\" ? (\n <>\n \n Shortcut saved\n \n ) : chord.phase === \"live\" ? (\n <>\n \n Recording\n \n ) : (\n \"Listening\"\n )}\n
\n
\n ) : null}\n
\n )\n}\n", + "content": "\"use client\"\n\nimport {\n Fragment,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n} from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n AmbientLight,\n BoxGeometry,\n BufferGeometry,\n CanvasTexture,\n Color,\n DataTexture,\n DepthTexture,\n DirectionalLight,\n ExtrudeGeometry,\n Float32BufferAttribute,\n Group,\n HalfFloatType,\n LatheGeometry,\n Mesh,\n MeshBasicMaterial,\n MeshNormalMaterial,\n MeshToonMaterial,\n NearestFilter,\n NoBlending,\n OrthographicCamera,\n Path,\n PlaneGeometry,\n Raycaster,\n RedFormat,\n Scene,\n ShaderMaterial,\n Shape,\n ShapeGeometry,\n SRGBColorSpace,\n Vector2,\n Vector3,\n WebGLRenderer,\n WebGLRenderTarget,\n type Material,\n type Texture,\n} from \"three\"\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration with\n * three.js: a milled case around a recessed plate, a volume knob, and 82\n * keycaps, shaded in three flat tone bands and outlined in ink traced from\n * depth and normals. Keys spring down under the real keyboard; chords are\n * recorded for a shortcut step in onboarding.\n *\n * Units are keys: 1 = one 1u keycap pitch. The desk is the XZ plane, y up,\n * +z toward the viewer.\n */\n\nexport type MechKeyboardPlatform = \"mac\" | \"pc\"\n\nexport type MechKeyboardKey = {\n /** `KeyboardEvent.code` of the key, or `\"Fn\"` */\n id: string\n /** Legend, as printed */\n label: string\n /** Shifted legend, printed after the label (`\"1 !\"`) */\n sub?: string\n /** Position from the top left of the plate and width, in key units */\n x: number\n y: number\n w: number\n /** Row: 0 is the function row */\n row: number\n}\n\nexport type MechKeyboardShortcut = {\n /** `KeyboardEvent.code`s, modifiers first */\n codes: string[]\n /** Display legends, one per key: `[\"⌘\", \"⇧\", \"K\"]` */\n keys: string[]\n /** `\"⌘⇧K\"` on Mac, `\"Ctrl+Shift+K\"` elsewhere */\n label: string\n}\n\nexport type MechKeyboardChordPhase = \"idle\" | \"live\" | \"done\"\n\nexport type MechKeyboardOptions = {\n /** Offsets in px; +y is down. Default 0. */\n x?: number\n y?: number\n /** Multiplies the fitted size. Default 1. */\n scale?: number\n /** Ink and greys only; no blue caps. Default false. */\n monochrome?: boolean\n /** Real key presses and clicks drive the keys. Default true. */\n interactive?: boolean\n /** Synthesized switch sounds. Default false. */\n sound?: boolean\n /** The keyboard rises into place and a wave runs across the keys. Default true. */\n intro?: boolean\n /** Codes to pulse. */\n highlight?: string[]\n /** Bottom-row legends and shortcut labels. Default: detected. */\n platform?: MechKeyboardPlatform\n /** Leave room at the top for the recorder. Default false. */\n recorder?: boolean\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n /** Live chord for the recorder. */\n onChord?: (codes: string[], phase: MechKeyboardChordPhase) => void\n}\n\nexport type MechKeyboardInstance = {\n setOptions: (next: Partial) => void\n /** Plays the intro again. */\n replay: () => void\n destroy: () => void\n}\n\nexport const DEFAULT_PLACEHOLDER = \"Press a shortcut\"\n\n/* ------------------------------------------------------------------ */\n/* Palette */\n/* ------------------------------------------------------------------ */\n\ntype Palette = {\n case: string\n plate: string\n /** Caps fade from the back left to the front right. */\n capBack: string\n capFront: string\n knob: string\n /** Recorder keycaps: top face and skirt */\n chip: string\n chipSide: string\n}\n\n/** Outlines, legends, and the knob's notch. */\nconst INK = \"#2B3442\"\nconst HIGHLIGHT = \"#7E9BBA\"\n\nconst FROST: Palette = {\n case: \"#E6E7E9\",\n plate: \"#8E96A3\",\n capBack: \"#AFC3D7\",\n capFront: \"#F1F2F4\",\n knob: \"#E2E4E7\",\n chip: \"#E9EEF4\",\n chipSide: \"#AFC3D7\",\n}\n\nconst MONO: Palette = {\n case: \"#F2F2F3\",\n plate: \"#A3A6AC\",\n capBack: \"#FAFAFA\",\n capFront: \"#FAFAFA\",\n knob: \"#F2F2F3\",\n chip: \"#FAFAFA\",\n chipSide: \"#CDD0D5\",\n}\n\nconst paletteFor = (monochrome: boolean) => (monochrome ? MONO : FROST)\n\n/** CSS variables the recorder keycaps read. */\nexport function recorderVars(monochrome = false) {\n const p = paletteFor(monochrome)\n return { \"--chip\": p.chip, \"--chip-side\": p.chipSide, \"--ink\": INK }\n}\n\n/* ------------------------------------------------------------------ */\n/* Layout */\n/* ------------------------------------------------------------------ */\n\nconst COLS = 16\n/** The function row, a quarter-unit gap, then five rows. */\nconst LAYOUT_D = 6.25\nconst KNOB_ID = \"AudioVolumeMute\"\n\ntype Spec = [id: string, w: number, label?: string, sub?: string]\n\nconst GAP_KEY = \"__gap\"\n\nconst gap = (w: number): Spec => [GAP_KEY, w]\nconst letters = (s: string): Spec[] => s.split(\"\").map((c) => [`Key${c}`, 1, c])\nconst fkeys = (from: number): Spec[] =>\n [0, 1, 2, 3].map((i) => [`F${from + i}`, 1, `F${from + i}`])\n\nfunction rows(platform: MechKeyboardPlatform): Spec[][] {\n const mac = platform === \"mac\"\n const bottom: Spec[] = mac\n ? [\n [\"ControlLeft\", 1.25, \"Control\"],\n [\"AltLeft\", 1.25, \"Option\"],\n [\"MetaLeft\", 1.25, \"Command\"],\n [\"Space\", 6.25, \"\"],\n [\"MetaRight\", 1, \"⌘\"],\n [\"AltRight\", 1, \"⌥\"],\n [\"Fn\", 1, \"Fn\"],\n ]\n : [\n [\"ControlLeft\", 1.25, \"Ctrl\"],\n [\"MetaLeft\", 1.25, \"Win\"],\n [\"AltLeft\", 1.25, \"Alt\"],\n [\"Space\", 6.25, \"\"],\n [\"AltRight\", 1, \"Alt\"],\n [\"Fn\", 1, \"Fn\"],\n [\"ControlRight\", 1, \"Ctrl\"],\n ]\n return [\n [\n [\"Escape\", 1, \"Esc\"],\n gap(0.25),\n ...fkeys(1),\n gap(0.25),\n ...fkeys(5),\n gap(0.25),\n ...fkeys(9),\n gap(0.25),\n [\"Delete\", 1, \"Del\"],\n ],\n [\n [\"Backquote\", 1, \"`\", \"~\"],\n [\"Digit1\", 1, \"1\", \"!\"],\n [\"Digit2\", 1, \"2\", \"@\"],\n [\"Digit3\", 1, \"3\", \"#\"],\n [\"Digit4\", 1, \"4\", \"$\"],\n [\"Digit5\", 1, \"5\", \"%\"],\n [\"Digit6\", 1, \"6\", \"^\"],\n [\"Digit7\", 1, \"7\", \"&\"],\n [\"Digit8\", 1, \"8\", \"*\"],\n [\"Digit9\", 1, \"9\", \"(\"],\n [\"Digit0\", 1, \"0\", \")\"],\n [\"Minus\", 1, \"-\", \"_\"],\n [\"Equal\", 1, \"=\", \"+\"],\n [\"Backspace\", 2, \"Backspace\"],\n [\"Home\", 1, \"Home\"],\n ],\n [\n [\"Tab\", 1.5, \"Tab\"],\n ...letters(\"QWERTYUIOP\"),\n [\"BracketLeft\", 1, \"[\", \"{\"],\n [\"BracketRight\", 1, \"]\", \"}\"],\n [\"Backslash\", 1.5, \"\\\\\", \"|\"],\n [\"PageUp\", 1, \"PgUp\"],\n ],\n [\n [\"CapsLock\", 1.75, \"Caps\"],\n ...letters(\"ASDFGHJKL\"),\n [\"Semicolon\", 1, \";\", \":\"],\n [\"Quote\", 1, \"'\", '\"'],\n [\"Enter\", 2.25, \"Enter\"],\n [\"PageDown\", 1, \"PgDn\"],\n ],\n [\n [\"ShiftLeft\", 2.25, \"Shift\"],\n ...letters(\"ZXCVBNM\"),\n [\"Comma\", 1, \",\", \"<\"],\n [\"Period\", 1, \".\", \">\"],\n [\"Slash\", 1, \"/\", \"?\"],\n [\"ShiftRight\", 1.75, \"Shift\"],\n [\"ArrowUp\", 1, \"↑\"],\n [\"End\", 1, \"End\"],\n ],\n [\n ...bottom,\n [\"ArrowLeft\", 1, \"←\"],\n [\"ArrowDown\", 1, \"↓\"],\n [\"ArrowRight\", 1, \"→\"],\n ],\n ]\n}\n\n/** The 82 keys of a 75% board, Mac or PC bottom row. */\nexport function buildLayout(platform: MechKeyboardPlatform): MechKeyboardKey[] {\n const keys: MechKeyboardKey[] = []\n rows(platform).forEach((row, r) => {\n const y = r === 0 ? 0 : r + 0.25\n let x = 0\n for (const [id, w, label = \"\", sub] of row) {\n if (id !== GAP_KEY) keys.push({ id, label, sub, x, y, w, row: r })\n x += w\n }\n })\n return keys\n}\n\n/* ------------------------------------------------------------------ */\n/* Codes, labels, shortcuts */\n/* ------------------------------------------------------------------ */\n\nexport function detectPlatform(): MechKeyboardPlatform {\n if (typeof navigator === \"undefined\") return \"pc\"\n const nav = navigator as Navigator & {\n userAgentData?: { platform?: string }\n }\n const name = nav.userAgentData?.platform || nav.platform || nav.userAgent\n return /mac|iphone|ipad|ipod/i.test(name) ? \"mac\" : \"pc\"\n}\n\nconst MODIFIER_ORDER: Record = {\n mac: [\"Control\", \"Alt\", \"Shift\", \"Meta\", \"Fn\"],\n pc: [\"Control\", \"Meta\", \"Alt\", \"Shift\", \"Fn\"],\n}\n\n/** `\"ShiftLeft\"` → `\"Shift\"`; anything else → undefined. */\nexport function modifierOf(code: string) {\n if (code === \"Fn\") return \"Fn\"\n const match = /^(Control|Alt|Shift|Meta|OS)(Left|Right)$/.exec(code)\n if (!match) return undefined\n return match[1] === \"OS\" ? \"Meta\" : match[1]\n}\n\nconst MAC_LABELS: Record = {\n Control: \"⌃\",\n Alt: \"⌥\",\n Shift: \"⇧\",\n Meta: \"⌘\",\n Fn: \"fn\",\n Escape: \"esc\",\n Tab: \"⇥\",\n CapsLock: \"⇪\",\n Enter: \"↩\",\n Backspace: \"⌫\",\n Delete: \"⌦\",\n Space: \"Space\",\n}\n\nconst PC_LABELS: Record = {\n Control: \"Ctrl\",\n Alt: \"Alt\",\n Shift: \"Shift\",\n Meta: \"Win\",\n Fn: \"Fn\",\n Escape: \"Esc\",\n Tab: \"Tab\",\n CapsLock: \"Caps\",\n Enter: \"Enter\",\n Backspace: \"Backspace\",\n Delete: \"Del\",\n Space: \"Space\",\n}\n\nconst SHARED_LABELS: Record = {\n ArrowUp: \"↑\",\n ArrowDown: \"↓\",\n ArrowLeft: \"←\",\n ArrowRight: \"→\",\n PageUp: \"PgUp\",\n PageDown: \"PgDn\",\n Home: \"Home\",\n End: \"End\",\n Insert: \"Ins\",\n Minus: \"-\",\n Equal: \"=\",\n BracketLeft: \"[\",\n BracketRight: \"]\",\n Backslash: \"\\\\\",\n IntlBackslash: \"\\\\\",\n Semicolon: \";\",\n Quote: \"'\",\n Comma: \",\",\n Period: \".\",\n Slash: \"/\",\n Backquote: \"`\",\n NumpadEnter: \"Enter\",\n AudioVolumeMute: \"Mute\",\n AudioVolumeUp: \"Vol +\",\n AudioVolumeDown: \"Vol −\",\n}\n\n/** One legend for a `KeyboardEvent.code`. */\nexport function keyLabel(code: string, platform: MechKeyboardPlatform) {\n const table = platform === \"mac\" ? MAC_LABELS : PC_LABELS\n const name = modifierOf(code) ?? code\n if (table[name]) return table[name]\n if (SHARED_LABELS[name]) return SHARED_LABELS[name]\n const match = /^(?:Key|Digit|Numpad)(.+)$/.exec(name)\n if (match) return match[1]!\n return name\n}\n\n/** Modifiers first in platform order (one per kind), then keys as pressed. */\nexport function formatShortcut(\n codes: string[],\n platform: MechKeyboardPlatform\n): MechKeyboardShortcut {\n const order = MODIFIER_ORDER[platform]\n const mods = new Map()\n const rest: string[] = []\n for (const code of codes) {\n const mod = modifierOf(code)\n if (mod) {\n if (!mods.has(mod)) mods.set(mod, code)\n } else if (!rest.includes(code)) {\n rest.push(code)\n }\n }\n const sorted = [\n ...order.filter((m) => mods.has(m)).map((m) => mods.get(m)!),\n ...rest,\n ]\n const keys = sorted.map((code) => keyLabel(code, platform))\n return {\n codes: sorted,\n keys,\n label: platform === \"mac\" ? keys.join(\"\") : keys.join(\"+\"),\n }\n}\n\nconst ALIASES: Record = {\n Insert: [\"Delete\"],\n NumpadEnter: [\"Enter\"],\n IntlBackslash: [\"Backslash\"],\n OSLeft: [\"MetaLeft\"],\n OSRight: [\"MetaRight\"],\n NumpadDecimal: [\"Period\"],\n NumpadDivide: [\"Slash\"],\n NumpadSubtract: [\"Minus\"],\n NumpadAdd: [\"Equal\"],\n AudioVolumeUp: [],\n AudioVolumeDown: [],\n}\n\n/** Which keycaps a code moves. */\nfunction capsFor(code: string): string[] {\n const pad = /^Numpad(\\d)$/.exec(code)\n if (pad) return [`Digit${pad[1]}`]\n return ALIASES[code] ?? [code]\n}\n\n/* ------------------------------------------------------------------ */\n/* Geometry */\n/* ------------------------------------------------------------------ */\n\nconst DEG = Math.PI / 180\n/** True isometric: the desk turned 45° and seen from 35.26° up. */\nconst ISO_TILT = 35.26 * DEG\nconst ISO_TURN = 45 * DEG\n/** Bezel around the plate */\nconst BEZEL = 0.48\nconst W = COLS + BEZEL * 2\nconst D = LAYOUT_D + BEZEL * 2\n/** Case height at the front; the back rises by RISE (typing angle). */\nconst CASE_H = 0.95\nconst RISE = 0.4\nconst TILT = Math.atan2(RISE, D)\n/** Bezel top down to the top of the plate */\nconst WELL = 0.5\n/** Gap around a cap in its cell, switch height under the cap, key travel */\nconst GAP = 0.035\nconst SWITCH = 0.12\nconst TRAVEL = 0.16\n/** Keycap: height, skirt taper, top edge radius, corner radius */\nconst CAP_H = 0.5\nconst CAP_TAPER = 0.07\nconst CAP_EDGE = 0.018\nconst CAP_RADIUS = 0.1\n\nfunction roundedRect(path: T, w: number, h: number, r: number) {\n const x = -w / 2\n const y = -h / 2\n path.moveTo(x + r, y)\n path.lineTo(x + w - r, y)\n path.quadraticCurveTo(x + w, y, x + w, y + r)\n path.lineTo(x + w, y + h - r)\n path.quadraticCurveTo(x + w, y + h, x + w - r, y + h)\n path.lineTo(x + r, y + h)\n path.quadraticCurveTo(x, y + h, x, y + h - r)\n path.lineTo(x, y + r)\n path.quadraticCurveTo(x, y, x + r, y)\n return path\n}\n\n/**\n * The milled frame around the well: extruded with one-segment bevels, so\n * every edge is a flat chamfer the ink can trace, then sheared into a\n * wedge.\n */\nfunction caseGeometry() {\n const chamfer = 0.05\n const outer = roundedRect(new Shape(), W - chamfer * 2, D - chamfer * 2, 0.16)\n outer.holes.push(\n roundedRect(\n new Path(),\n COLS + 0.12 + chamfer * 2,\n LAYOUT_D + 0.12 + chamfer * 2,\n 0.06\n )\n )\n const geometry = new ExtrudeGeometry(outer, {\n depth: CASE_H - chamfer * 2,\n bevelEnabled: true,\n bevelThickness: chamfer,\n bevelSize: chamfer,\n bevelSegments: 1,\n curveSegments: 8,\n })\n geometry.rotateX(-Math.PI / 2)\n geometry.translate(0, chamfer, 0)\n const pos = geometry.attributes.position!\n for (let i = 0; i < pos.count; i++) {\n const y = pos.getY(i)\n const back = Math.min(1, Math.max(0, (D / 2 - pos.getZ(i)) / D))\n pos.setY(i, y + back * RISE * Math.min(1, Math.max(0, y / CASE_H)))\n }\n geometry.computeVertexNormals()\n return geometry\n}\n\n/** Top of the bezel at depth z (0 is the middle). */\nconst bezelTop = (z: number) => CASE_H + ((D / 2 - z) / D) * RISE\n\n/** Points around a rounded rectangle, back right corner first. */\nfunction ring(hw: number, hd: number, r: number, seg: number) {\n const out: Array<[number, number]> = []\n const corners: Array<[number, number, number]> = [\n [hw - r, -(hd - r), -90],\n [hw - r, hd - r, 0],\n [-(hw - r), hd - r, 90],\n [-(hw - r), -(hd - r), 180],\n ]\n for (const [cx, cz, a0] of corners) {\n for (let i = 0; i <= seg; i++) {\n const a = (a0 + (90 * i) / seg) * DEG\n out.push([cx + Math.cos(a) * r, cz + Math.sin(a) * r])\n }\n }\n return out\n}\n\n/**\n * A keycap as one smooth surface: a tapered skirt and a crisp top edge.\n * UVs are a projection from above over the footprint, so a canvas the size\n * of the footprint prints its legend onto the top.\n */\nfunction capGeometry(w: number) {\n const fw = w - GAP * 2\n const fd = 1 - GAP * 2\n const profile: Array<[inset: number, y: number]> = []\n for (let i = 0; i <= 2; i++) {\n const t = i / 2\n profile.push([CAP_TAPER * t, (CAP_H - CAP_EDGE) * t])\n }\n for (let i = 1; i <= 3; i++) {\n const a = (i / 3) * (Math.PI / 2)\n profile.push([\n CAP_TAPER + CAP_EDGE * (1 - Math.cos(a)),\n CAP_H - CAP_EDGE + CAP_EDGE * Math.sin(a),\n ])\n }\n\n const rings: Array> = []\n for (const [inset, y] of profile) {\n const r = Math.max(0.03, CAP_RADIUS - inset * 0.35)\n rings.push(\n ring(fw / 2 - inset, fd / 2 - inset, r, 5).map(([x, z]) => [x, y, z])\n )\n }\n const top = rings[rings.length - 1]!\n for (const s of [0.5]) rings.push(top.map(([x, y, z]) => [x * s, y, z * s]))\n\n const positions: number[] = []\n const uvs: number[] = []\n for (const points of rings) {\n for (const [x, y, z] of points) {\n positions.push(x, y, z)\n uvs.push(0.5 + x / fw, 0.5 - z / fd)\n }\n }\n positions.push(0, CAP_H, 0)\n uvs.push(0.5, 0.5)\n\n const n = top.length\n const index: number[] = []\n for (let i = 0; i < rings.length - 1; i++) {\n for (let j = 0; j < n; j++) {\n const a = i * n + j\n const b = i * n + ((j + 1) % n)\n const c = (i + 1) * n + j\n const d = (i + 1) * n + ((j + 1) % n)\n index.push(a, c, b, b, c, d)\n }\n }\n const center = rings.length * n\n const last = (rings.length - 1) * n\n for (let j = 0; j < n; j++) {\n index.push(last + j, center, last + ((j + 1) % n))\n }\n\n const geometry = new BufferGeometry()\n geometry.setAttribute(\"position\", new Float32BufferAttribute(positions, 3))\n geometry.setAttribute(\"uv\", new Float32BufferAttribute(uvs, 2))\n geometry.setIndex(index)\n geometry.computeVertexNormals()\n return geometry\n}\n\n/** The volume knob, turned on a lathe. */\nfunction knobGeometry() {\n const r = 0.4\n const h = 0.62\n const points = [\n new Vector2(0, 0),\n new Vector2(r - 0.02, 0),\n new Vector2(r, 0.02),\n new Vector2(r, h - 0.03),\n new Vector2(r - 0.03, h),\n new Vector2(0, h),\n ]\n return new LatheGeometry(points, 64)\n}\n\n/* ------------------------------------------------------------------ */\n/* Textures */\n/* ------------------------------------------------------------------ */\n\nfunction canvas(w: number, h: number) {\n const el = document.createElement(\"canvas\")\n el.width = w\n el.height = h\n return [el, el.getContext(\"2d\")!] as const\n}\n\n/** Three flat tone bands. */\nfunction rampTexture() {\n const texture = new DataTexture(\n new Uint8Array([120, 196, 255]),\n 3,\n 1,\n RedFormat\n )\n texture.minFilter = NearestFilter\n texture.magFilter = NearestFilter\n texture.generateMipmaps = false\n texture.needsUpdate = true\n return texture\n}\n\nconst LEGEND_PX = 256\n\n/** Arrow legends, as the angle of a right-pointing arrow. */\nconst ARROWS: Record = {\n \"→\": 0,\n \"↓\": Math.PI / 2,\n \"←\": Math.PI,\n \"↑\": -Math.PI / 2,\n}\n\n/** The cap's color with its legend in ink, like the outlines. */\nfunction capTexture(key: MechKeyboardKey, color: string, font: string) {\n const fw = key.w - GAP * 2\n const fd = 1 - GAP * 2\n const [el, ctx] = canvas(\n Math.round(fw * LEGEND_PX),\n Math.round(fd * LEGEND_PX)\n )\n ctx.fillStyle = color\n ctx.fillRect(0, 0, el.width, el.height)\n ctx.fillStyle = INK\n ctx.strokeStyle = INK\n const cx = el.width / 2\n const cy = el.height * 0.47\n const arrow = ARROWS[key.label]\n if (arrow !== undefined) {\n // Drawn, not typed: plenty of UI fonts have no ← or →.\n const len = 0.17 * LEGEND_PX\n ctx.save()\n ctx.translate(cx, cy)\n ctx.rotate(arrow)\n ctx.lineWidth = 0.02 * LEGEND_PX\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n ctx.beginPath()\n ctx.moveTo(-len / 2, 0)\n ctx.lineTo(len / 2, 0)\n ctx.moveTo(len / 2 - len * 0.32, -len * 0.3)\n ctx.lineTo(len / 2, 0)\n ctx.lineTo(len / 2 - len * 0.32, len * 0.3)\n ctx.stroke()\n ctx.restore()\n } else {\n const single = [...key.label].length === 1 && !key.sub\n const text = key.sub\n ? `${key.label} ${key.sub}`\n : single\n ? key.label\n : key.label.toUpperCase()\n const size = single ? 0.22 : key.sub ? 0.17 : 0.125\n ctx.font = `600 ${size * LEGEND_PX}px ${font}`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n if (!single) ctx.letterSpacing = `${0.014 * LEGEND_PX}px`\n ctx.fillText(text, cx, cy)\n }\n const texture = new CanvasTexture(el)\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = 8\n return texture\n}\n\n/* ------------------------------------------------------------------ */\n/* Sound */\n/* ------------------------------------------------------------------ */\n\nfunction createThock() {\n let audio: AudioContext | null = null\n let noise: AudioBuffer | null = null\n let master: GainNode | null = null\n return {\n play(code: string, down: boolean) {\n try {\n if (!audio) {\n const Ctx =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext\n audio = new Ctx()\n master = audio.createGain()\n master.gain.value = 0.55\n master.connect(audio.destination)\n noise = audio.createBuffer(\n 1,\n Math.floor(audio.sampleRate * 0.06),\n audio.sampleRate\n )\n const data = noise.getChannelData(0)\n for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1\n }\n if (audio.state === \"suspended\") void audio.resume()\n const t = audio.currentTime\n const big =\n code === \"Space\" ||\n code === \"Enter\" ||\n code === \"Backspace\" ||\n code.startsWith(\"Shift\")\n const vary = 0.93 + Math.random() * 0.14\n\n const body = audio.createOscillator()\n body.type = \"sine\"\n const f = 132 * (big ? 0.72 : 1) * (down ? 1 : 1.3) * vary\n body.frequency.setValueAtTime(f * 1.9, t)\n body.frequency.exponentialRampToValueAtTime(f, t + 0.03)\n const bodyGain = audio.createGain()\n bodyGain.gain.setValueAtTime(down ? 0.32 : 0.1, t)\n bodyGain.gain.exponentialRampToValueAtTime(\n 0.0001,\n t + (down ? 0.1 : 0.05)\n )\n body.connect(bodyGain).connect(master!)\n body.start(t)\n body.stop(t + 0.12)\n\n const hit = audio.createBufferSource()\n hit.buffer = noise\n const band = audio.createBiquadFilter()\n band.type = \"bandpass\"\n band.frequency.value = 2900 * (down ? 1 : 1.25) * vary\n band.Q.value = 1.1\n const hitGain = audio.createGain()\n hitGain.gain.setValueAtTime(down ? 0.5 : 0.22, t)\n hitGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.035)\n hit.connect(band).connect(hitGain).connect(master!)\n hit.start(t)\n hit.stop(t + 0.06)\n } catch {\n /* no audio: stay silent */\n }\n },\n close() {\n void audio?.close()\n },\n }\n}\n\nfunction isEditable(target: EventTarget | null) {\n if (!(target instanceof HTMLElement)) return false\n if (target.isContentEditable) return true\n const tag = target.tagName\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return true\n if (tag !== \"INPUT\") return false\n const type = (target as HTMLInputElement).type\n return ![\n \"checkbox\",\n \"radio\",\n \"button\",\n \"range\",\n \"color\",\n \"submit\",\n \"reset\",\n ].includes(type)\n}\n\n/* ------------------------------------------------------------------ */\n/* Engine */\n/* ------------------------------------------------------------------ */\n\ntype Resolved = Required<\n Omit\n> &\n Pick & {\n highlight: string[]\n }\n\nfunction resolve(options: MechKeyboardOptions): Resolved {\n const num = (v: number | undefined, d: number) =>\n typeof v === \"number\" && Number.isFinite(v) ? v : d\n return {\n x: num(options.x, 0),\n y: num(options.y, 0),\n scale: Math.max(0.05, num(options.scale, 1)),\n monochrome: options.monochrome ?? false,\n interactive: options.interactive ?? true,\n sound: options.sound ?? false,\n intro: options.intro ?? true,\n highlight: options.highlight ?? [],\n platform:\n options.platform === \"mac\" || options.platform === \"pc\"\n ? options.platform\n : detectPlatform(),\n recorder: options.recorder ?? false,\n onKey: options.onKey,\n onShortcut: options.onShortcut,\n onChord: options.onChord,\n }\n}\n\ntype Cap = {\n id: string\n x: number\n y: number\n holder: Group\n /** Rest height of the holder */\n base: number\n /** How far it goes down */\n travel: number\n material: MeshToonMaterial\n pos: number\n vel: number\n target: number\n}\n\nconst NEAR = 0.1\nconst FAR = 200\n\n/**\n * Mounts a canvas in `stage` and listens on `root`\n * (focusable; pointer and keys). Returns a no-op instance without WebGL.\n */\nexport function createMechKeyboard(\n root: HTMLElement,\n stage: HTMLElement,\n initial: MechKeyboardOptions = {}\n): MechKeyboardInstance {\n let opts = resolve(initial)\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({ alpha: true, antialias: true })\n } catch {\n return { setOptions() {}, replay() {}, destroy() {} }\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const el = renderer.domElement\n el.style.display = \"block\"\n el.style.width = \"100%\"\n el.style.height = \"100%\"\n stage.appendChild(el)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let reduced = mqReduce.matches\n let destroyed = false\n const disposables: Array = []\n const keep = (item: T) => {\n disposables.push(item)\n return item\n }\n\n /* ---------- scene ---------- */\n\n const scene = new Scene()\n const camera = new OrthographicCamera(-1, 1, 1, -1, NEAR, FAR)\n\n // desk: tipped toward the camera. board: turned 45° on the desk.\n const desk = new Group()\n desk.rotation.x = ISO_TILT\n scene.add(desk)\n const board = new Group()\n board.rotation.y = ISO_TURN\n board.position.y = -(CASE_H + RISE / 2) / 2\n desk.add(board)\n\n // Light fixed to the view, the illustrator's convention: tops lightest,\n // left faces middle, right faces darkest.\n const key = new DirectionalLight(0xffffff, 1.9)\n key.position.set(-3, 6, 4)\n scene.add(key, key.target, new AmbientLight(0xffffff, 1.1))\n\n // A flat, hard-edged shadow, offset down and to the right.\n const shadow = new Mesh(\n keep(\n new ShapeGeometry(roundedRect(new Shape(), W, D, 0.16), 8).rotateX(\n -Math.PI / 2\n )\n ),\n keep(\n new MeshBasicMaterial({\n color: INK,\n transparent: true,\n opacity: 0.1,\n depthWrite: false,\n })\n )\n )\n shadow.rotation.y = ISO_TURN\n shadow.position.set(0.55, board.position.y + 0.003, 0.75)\n desk.add(shadow)\n\n const ramp = keep(rampTexture())\n const caseGeo = keep(caseGeometry())\n const knobGeo = keep(knobGeometry())\n const capGeos = new Map()\n const capGeo = (w: number) => {\n let g = capGeos.get(w)\n if (!g) capGeos.set(w, (g = keep(capGeometry(w))))\n return g\n }\n\n /* ---------- the board, rebuilt per palette, platform, and font ---------- */\n\n let built: {\n group: Group\n disposables: Array\n knob: Mesh\n pickable: Mesh[]\n } | null = null\n let builtFor = \"\"\n const caps = new Map()\n let knobAngle = 0\n\n const fontFamily = () =>\n getComputedStyle(root).fontFamily || \"ui-sans-serif, system-ui, sans-serif\"\n const plateX = (x: number) => -COLS / 2 + x\n const plateZ = (y: number) => -LAYOUT_D / 2 + y\n\n const build = () => {\n const font = fontFamily()\n const stamp = `${opts.monochrome}|${opts.platform}|${font}`\n if (stamp === builtFor) return\n builtFor = stamp\n const palette = paletteFor(opts.monochrome)\n const own: Array = []\n const take = (item: T) => {\n own.push(item)\n return item\n }\n const toon = (color: Color | string, map: Texture | null = null) =>\n take(\n new MeshToonMaterial({\n color,\n map,\n gradientMap: ramp,\n emissive: new Color(HIGHLIGHT),\n emissiveIntensity: 0,\n })\n )\n\n const group = new Group()\n const caseMat = toon(palette.case)\n group.add(new Mesh(caseGeo, caseMat))\n const bottom = new Mesh(\n take(new BoxGeometry(W - 0.4, 0.06, D - 0.4)),\n caseMat\n )\n bottom.position.y = 0.03\n group.add(bottom)\n\n // Plate and keys ride the tipped top.\n const deck = new Group()\n deck.position.y = bezelTop(0) - WELL\n deck.rotation.x = TILT\n group.add(deck)\n\n const plate = new Mesh(\n take(new BoxGeometry(COLS + 0.2, 0.08, LAYOUT_D + 0.2)),\n toon(palette.plate)\n )\n plate.position.y = -0.04\n deck.add(plate)\n\n const prev = new Map(caps)\n caps.clear()\n\n const knobMat = toon(palette.knob)\n const knobHolder = new Group()\n knobHolder.position.set(plateX(15.5), 0.08, plateZ(0.5))\n deck.add(knobHolder)\n const knob = new Mesh(knobGeo, knobMat)\n knob.userData.id = KNOB_ID\n knob.rotation.y = knobAngle\n knobHolder.add(knob)\n const notch = new Mesh(\n take(new BoxGeometry(0.05, 0.012, 0.2)),\n take(new MeshBasicMaterial({ color: INK }))\n )\n notch.position.set(0, 0.622, -0.19)\n knob.add(notch)\n const pickable: Mesh[] = [knob]\n\n const next: Cap[] = [\n {\n id: KNOB_ID,\n x: 15,\n y: 0,\n holder: knobHolder,\n base: 0.08,\n travel: 0.06,\n material: knobMat,\n pos: 0,\n vel: 0,\n target: 0,\n },\n ]\n\n const from = new Color(palette.capBack)\n const to = new Color(palette.capFront)\n for (const k of buildLayout(opts.platform)) {\n const cx = plateX(k.x + k.w / 2)\n const cz = plateZ(k.y + 0.5)\n const holder = new Group()\n holder.position.set(cx, SWITCH, cz)\n deck.add(holder)\n\n // The back rows carry the color; it washes out toward the front right.\n const t = Math.min(\n 1,\n Math.max(\n 0,\n (k.y / 5.25) * 0.82 + ((k.x + k.w / 2) / COLS) * 0.26 - 0.08\n )\n )\n const color = from.clone().lerp(to, t)\n const map = k.label ? take(capTexture(k, color.getStyle(), font)) : null\n const material = toon(map ? \"#ffffff\" : color, map)\n const mesh = new Mesh(capGeo(k.w), material)\n mesh.userData.id = k.id\n holder.add(mesh)\n pickable.push(mesh)\n next.push({\n id: k.id,\n x: k.x,\n y: k.y,\n holder,\n base: SWITCH,\n travel: TRAVEL,\n material,\n pos: 0,\n vel: 0,\n target: 0,\n })\n }\n\n // Keys that were down stay down across a rebuild.\n for (const cap of next) {\n const old = prev.get(cap.id)\n if (old) {\n cap.pos = old.pos\n cap.target = old.target\n }\n cap.holder.position.y = cap.base + cap.pos\n caps.set(cap.id, cap)\n }\n\n board.add(group)\n if (built) {\n board.remove(built.group)\n for (const item of built.disposables) item.dispose()\n }\n built = { group, disposables: own, knob, pickable }\n paintHighlights()\n }\n\n /* ---------- ink: edges from depth and normals ---------- */\n\n const color = new WebGLRenderTarget(1, 1, { type: HalfFloatType, samples: 4 })\n const normal = new WebGLRenderTarget(1, 1, { type: HalfFloatType })\n normal.depthTexture = new DepthTexture(1, 1)\n const normalMat = keep(new MeshNormalMaterial())\n const quad = new Mesh(\n keep(new PlaneGeometry(2, 2)),\n keep(\n new ShaderMaterial({\n uniforms: {\n tColor: { value: color.texture },\n tNormal: { value: normal.texture },\n tDepth: { value: normal.depthTexture },\n texel: { value: new Vector2() },\n ink: { value: new Color(INK) },\n depthRange: { value: FAR - NEAR },\n },\n vertexShader: INK_VERTEX,\n fragmentShader: INK_FRAGMENT,\n depthTest: false,\n depthWrite: false,\n blending: NoBlending,\n })\n )\n )\n const post = new Scene()\n post.add(quad)\n const postCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)\n\n const render = () => {\n renderer.setRenderTarget(color)\n renderer.setClearColor(0x000000, 0)\n renderer.render(scene, camera)\n\n shadow.visible = false\n scene.overrideMaterial = normalMat\n renderer.setRenderTarget(normal)\n renderer.setClearColor(0x8080ff, 1)\n renderer.render(scene, camera)\n scene.overrideMaterial = null\n shadow.visible = true\n\n renderer.setClearColor(0x000000, 0)\n renderer.setRenderTarget(null)\n renderer.render(post, postCamera)\n }\n\n /* ---------- fit and offset ---------- */\n\n let width = 0\n let height = 0\n const current = { x: 0, y: 0 }\n let introFrom: { x: number; y: number } | null = null\n let introStart = 0\n const INTRO_MS = 1300\n\n const corners: Vector3[] = []\n for (const x of [-W / 2, W / 2])\n for (const y of [0, CASE_H + RISE + 0.1])\n for (const z of [-D / 2, D / 2]) corners.push(new Vector3(x, y, z))\n const scratch = new Vector3()\n\n /** Frame the board exactly: measure its projected box straight on. */\n const fit = () => {\n if (!width || !height) return\n desk.position.set(0, 0, 0)\n desk.updateMatrixWorld(true)\n let minX = Infinity\n let maxX = -Infinity\n let minY = Infinity\n let maxY = -Infinity\n for (const c of corners) {\n scratch.copy(c).applyMatrix4(board.matrixWorld)\n minX = Math.min(minX, scratch.x)\n maxX = Math.max(maxX, scratch.x)\n minY = Math.min(minY, scratch.y)\n maxY = Math.max(maxY, scratch.y)\n }\n const aspect = width / height\n const room = opts.recorder ? 0.66 : 0.82\n const half =\n Math.max((maxY - minY) / 2 / room, (maxX - minX) / 2 / 0.88 / aspect) /\n opts.scale\n camera.left = -half * aspect\n camera.right = half * aspect\n camera.top = half\n camera.bottom = -half\n camera.position.set((minX + maxX) / 2, (minY + maxY) / 2, FAR / 2)\n camera.lookAt(camera.position.x, camera.position.y, 0)\n camera.updateProjectionMatrix()\n place()\n }\n\n function place() {\n const perPx = (camera.top - camera.bottom) / (height || 1)\n const drop = opts.recorder ? height * 0.04 : 0\n desk.position.set(current.x * perPx, -(current.y + drop) * perPx, 0)\n }\n\n const resize = () => {\n const w = stage.clientWidth\n const h = stage.clientHeight\n if (!w || !h || (w === width && h === height)) return false\n width = w\n height = h\n renderer.setSize(w, h, false)\n const pr = renderer.getPixelRatio()\n const pw = Math.max(1, Math.round(w * pr))\n const ph = Math.max(1, Math.round(h * pr))\n // Normals and depth at twice the resolution on 1x screens, so the\n // four-tap average in the ink pass has subpixels to antialias with.\n const s = pr < 2 ? 2 : 1\n color.setSize(pw, ph)\n normal.setSize(pw * s, ph * s)\n quad.material.uniforms.texel!.value.set(1 / pw, 1 / ph)\n fit()\n return true\n }\n\n /* ---------- loop ---------- */\n\n let raf = 0\n let last = 0\n let onScreen = true\n let pulse = 0\n let knobGoal = 0\n\n const wake = () => {\n if (raf || destroyed || !onScreen || document.hidden) return\n last = performance.now()\n raf = requestAnimationFrame(frame)\n }\n\n const frame = (now: number) => {\n raf = 0\n if (destroyed) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n let busy = false\n\n // Offset: the intro rise, then exponential smoothing toward the props.\n if (introFrom) {\n const t = Math.min(1, (now - introStart) / INTRO_MS)\n const e = 1 - Math.pow(1 - t, 4)\n current.x = introFrom.x + (opts.x - introFrom.x) * e\n current.y = introFrom.y + (opts.y - introFrom.y) * e\n if (t >= 1) introFrom = null\n busy = true\n } else {\n const a = reduced ? 1 : 1 - Math.exp(-dt * 7)\n for (const k of [\"x\", \"y\"] as const) {\n const delta = opts[k] - current[k]\n if (Math.abs(delta) > 0.05) {\n current[k] += delta * a\n busy = true\n } else {\n current[k] = opts[k]\n }\n }\n }\n place()\n\n // Keys: a stiff spring down, a bouncier one back up, in fixed 4 ms\n // substeps (one Euler step this stiff blows up on a slow frame).\n const steps = Math.max(1, Math.ceil(dt / 0.004))\n const h = dt / steps\n for (const cap of caps.values()) {\n if (cap.pos === cap.target && cap.vel === 0) continue\n const down = cap.target < cap.pos\n const k = down ? 2600 : 900\n const c = down ? 80 : 26\n for (let i = 0; i < steps; i++) {\n cap.vel += (-k * (cap.pos - cap.target) - c * cap.vel) * h\n cap.pos += cap.vel * h\n }\n if (\n reduced ||\n (Math.abs(cap.pos - cap.target) < 0.0005 && Math.abs(cap.vel) < 0.01)\n ) {\n cap.pos = cap.target\n cap.vel = 0\n } else {\n busy = true\n }\n cap.holder.position.y = cap.base + cap.pos\n }\n\n const turn = knobGoal - knobAngle\n if (Math.abs(turn) > 0.0005) {\n knobAngle += turn * (reduced ? 1 : 1 - Math.exp(-dt * 14))\n if (built) built.knob.rotation.y = knobAngle\n busy = true\n }\n\n if (opts.highlight.length) {\n pulse += dt\n const glow = reduced ? 0.3 : 0.2 + 0.16 * Math.sin(pulse * 4)\n for (const id of opts.highlight) {\n const cap = caps.get(id)\n if (cap) cap.material.emissiveIntensity = glow\n }\n if (!reduced) busy = true\n }\n\n render()\n if (busy) wake()\n }\n\n function paintHighlights() {\n const set = new Set(opts.highlight)\n for (const [id, cap] of caps) {\n cap.material.emissiveIntensity = set.has(id) ? 0.3 : 0\n }\n }\n\n /* ---------- keys ---------- */\n\n const held = new Set()\n const latched = new Set()\n let chord: string[] = []\n const thock = createThock()\n\n const capFor = (id: string) => {\n const cap = caps.get(id)\n if (cap || !/(Left|Right)$/.test(id)) return cap\n const twin = id.endsWith(\"Left\")\n ? id.replace(/Left$/, \"Right\")\n : id.replace(/Right$/, \"Left\")\n return caps.get(twin)\n }\n\n const paint = () => {\n const down = new Set()\n for (const code of [...held, ...latched]) {\n for (const id of capsFor(code)) {\n const cap = capFor(id)\n if (cap) down.add(cap)\n }\n }\n for (const cap of caps.values()) {\n cap.target = down.has(cap) ? -cap.travel : 0\n }\n wake()\n }\n\n const sound = (code: string, down: boolean) => {\n if (opts.sound) thock.play(code, down)\n }\n\n const press = (code: string) => {\n if (held.has(code)) return\n if (code === \"AudioVolumeUp\") knobGoal -= 0.35\n if (code === \"AudioVolumeDown\") knobGoal += 0.35\n if (held.size === 0 && latched.size === 0) chord = []\n held.add(code)\n if (!chord.includes(code)) chord.push(code)\n paint()\n sound(code, true)\n opts.onKey?.(code, true)\n opts.onChord?.([...chord], \"live\")\n }\n\n const commit = () => {\n if (held.size || latched.size || chord.length === 0) return\n const done = chord\n chord = []\n opts.onChord?.(done, \"done\")\n opts.onShortcut?.(formatShortcut(done, opts.platform))\n }\n\n const release = (code: string) => {\n if (!held.delete(code)) return\n paint()\n sound(code, false)\n opts.onKey?.(code, false)\n commit()\n }\n\n const releaseAll = () => {\n const codes = [...held]\n held.clear()\n latched.clear()\n paint()\n for (const code of codes) opts.onKey?.(code, false)\n commit()\n }\n\n const timers = new Set>()\n const later = (fn: () => void, ms: number) => {\n const id = setTimeout(() => {\n timers.delete(id)\n if (!destroyed) fn()\n }, ms)\n timers.add(id)\n }\n\n // The real OS, not the `platform` legends option: it decides which\n // keyboard events arrive.\n const onMac = detectPlatform() === \"mac\"\n\n const focused = () =>\n document.activeElement === root || root.contains(document.activeElement)\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!opts.interactive || !event.code) return\n if (isEditable(event.target) && !root.contains(event.target as Node)) return\n if (focused() && event.code !== \"Tab\") event.preventDefault()\n if (event.repeat) return\n press(event.code)\n // macOS fires one event per Caps Lock toggle; never leave it stuck.\n // Elsewhere Caps Lock sends a normal down and up, so it can be held.\n if (event.code === \"CapsLock\" && onMac) {\n later(() => release(\"CapsLock\"), 160)\n }\n }\n\n const onKeyUp = (event: KeyboardEvent) => {\n if (!event.code) return\n if (event.code === \"CapsLock\" && !held.has(\"CapsLock\")) {\n if (!opts.interactive) return\n press(\"CapsLock\")\n later(() => release(\"CapsLock\"), 120)\n return\n }\n // While ⌘ is held macOS swallows other keyups; release them with it.\n if (modifierOf(event.code) === \"Meta\") {\n for (const code of [...held]) if (!modifierOf(code)) release(code)\n }\n release(event.code)\n }\n\n /* Pointer: modifiers latch, a non-modifier fires the chord. */\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const pick = (event: { clientX: number; clientY: number }) => {\n if (!built) return null\n const r = el.getBoundingClientRect()\n if (!r.width || !r.height) return null\n ndc.set(\n ((event.clientX - r.left) / r.width) * 2 - 1,\n -((event.clientY - r.top) / r.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n const hit = raycaster.intersectObjects(built.pickable, false)[0]\n return (hit?.object.userData.id as string | undefined) ?? null\n }\n\n /** Keys held down by each pointer, so two fingers release independently. */\n const pointers = new Map()\n\n const onPointerDown = (event: PointerEvent) => {\n if (!opts.interactive || event.button !== 0) return\n const code = pick(event)\n if (!code) return\n event.preventDefault()\n root.focus({ preventScroll: true })\n if (modifierOf(code)) {\n if (latched.has(code)) {\n latched.delete(code)\n chord = chord.filter((c) => c !== code)\n paint()\n opts.onChord?.([...chord], chord.length ? \"live\" : \"idle\")\n } else {\n if (held.size === 0 && latched.size === 0) chord = []\n latched.add(code)\n if (!chord.includes(code)) chord.push(code)\n paint()\n sound(code, true)\n opts.onChord?.([...chord], \"live\")\n }\n return\n }\n pointers.set(event.pointerId, code)\n press(code)\n }\n\n const onPointerUp = (event: PointerEvent) => {\n const code = pointers.get(event.pointerId)\n if (!code) return\n pointers.delete(event.pointerId)\n if (![...pointers.values()].includes(code)) {\n held.delete(code)\n sound(code, false)\n opts.onKey?.(code, false)\n }\n // Latched modifiers belong to the chord until the last finger lifts.\n if (pointers.size === 0) latched.clear()\n paint()\n commit()\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!opts.interactive || event.pointerType !== \"mouse\") return\n el.style.cursor = pick(event) ? \"pointer\" : \"\"\n }\n\n /* The knob turns under the wheel, like the real one. */\n const onWheel = (event: WheelEvent) => {\n if (!opts.interactive || pick(event) !== KNOB_ID) return\n event.preventDefault()\n knobGoal += Math.sign(event.deltaY) * 0.26\n wake()\n }\n\n const onBlur = () => {\n if (held.size || latched.size) releaseAll()\n }\n\n window.addEventListener(\"keydown\", onKeyDown)\n window.addEventListener(\"keyup\", onKeyUp)\n window.addEventListener(\"blur\", onBlur)\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"pointermove\", onPointerMove)\n root.addEventListener(\"wheel\", onWheel, { passive: false })\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onPointerUp)\n\n /* ---------- lifecycle ---------- */\n\n const ro = new ResizeObserver(() => {\n if (!resize() || destroyed) return\n // Resizing cleared the canvas; draw the current frame now.\n render()\n wake()\n })\n ro.observe(stage)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(stage)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => {\n reduced = mqReduce.matches\n wake()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n // Legends are printed from a canvas; print them again once fonts land.\n void document.fonts?.ready.then(() => {\n if (destroyed) return\n builtFor = \"\"\n build()\n wake()\n })\n\n const wave = () => {\n for (const cap of caps.values()) {\n const delay = 380 + cap.x * 36 + cap.y * 24\n later(() => {\n if (cap.target !== 0) return\n cap.target = -cap.travel * 0.8\n wake()\n later(paint, 110)\n }, delay)\n }\n }\n\n const intro = () => {\n stage.style.transition = \"opacity 800ms ease-out\"\n if (!opts.intro || reduced) {\n current.x = opts.x\n current.y = opts.y\n stage.style.opacity = \"1\"\n wake()\n return\n }\n introFrom = { x: opts.x, y: opts.y + 60 }\n Object.assign(current, introFrom)\n introStart = performance.now()\n stage.style.opacity = \"0\"\n requestAnimationFrame(() => {\n if (!destroyed) stage.style.opacity = \"1\"\n })\n wave()\n wake()\n }\n\n build()\n resize()\n intro()\n\n return {\n setOptions(next) {\n opts = resolve({ ...opts, ...next })\n if (!opts.interactive && (held.size || latched.size)) releaseAll()\n build()\n fit()\n paintHighlights()\n wake()\n },\n replay() {\n intro()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n for (const id of timers) clearTimeout(id)\n timers.clear()\n ro.disconnect()\n io.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"keydown\", onKeyDown)\n window.removeEventListener(\"keyup\", onKeyUp)\n window.removeEventListener(\"blur\", onBlur)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"wheel\", onWheel)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onPointerUp)\n thock.close()\n if (built) for (const item of built.disposables) item.dispose()\n for (const item of disposables) item.dispose()\n color.dispose()\n normal.depthTexture?.dispose()\n normal.dispose()\n renderer.dispose()\n // dispose() keeps the context until garbage collection, and browsers\n // cap live contexts; give it back now.\n renderer.forceContextLoss()\n el.remove()\n },\n }\n}\n\nconst INK_VERTEX = /* glsl */ `\nvarying vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n`\n\n/**\n * Ink where depth jumps (silhouettes, caps over the plate) or the surface\n * turns sharply (case chamfers, cap edges). Four subpixel taps averaged,\n * so the lines come out antialiased.\n */\nconst INK_FRAGMENT = /* glsl */ `\nuniform sampler2D tColor;\nuniform sampler2D tNormal;\nuniform sampler2D tDepth;\nuniform vec2 texel;\nuniform vec3 ink;\nuniform float depthRange;\nvarying vec2 vUv;\n\nfloat edgeAt(vec2 uv) {\n float d0 = texture2D(tDepth, uv).r;\n vec3 n0 = texture2D(tNormal, uv).rgb * 2.0 - 1.0;\n float e = 0.0;\n vec2 dirs[4];\n dirs[0] = vec2(1.0, 0.0);\n dirs[1] = vec2(-1.0, 0.0);\n dirs[2] = vec2(0.0, 1.0);\n dirs[3] = vec2(0.0, -1.0);\n for (int i = 0; i < 4; i++) {\n vec2 q = uv + dirs[i] * texel * 0.85;\n float dd = abs(texture2D(tDepth, q).r - d0) * depthRange;\n vec3 n1 = texture2D(tNormal, q).rgb * 2.0 - 1.0;\n e = max(e, smoothstep(0.07, 0.14, dd));\n e = max(e, smoothstep(0.22, 0.4, 1.0 - dot(n0, n1)));\n }\n return e;\n}\n\nvoid main() {\n vec4 color = texture2D(tColor, vUv);\n vec2 s = texel * 0.25;\n float e = (\n edgeAt(vUv + vec2(-s.x, -s.y)) +\n edgeAt(vUv + vec2(s.x, -s.y)) +\n edgeAt(vUv + vec2(-s.x, s.y)) +\n edgeAt(vUv + vec2(s.x, s.y))\n ) * 0.25;\n gl_FragColor = mix(color, vec4(ink, 1.0), e);\n #include \n}\n`\n\n/** Tailwind for the recorder, shared by the React and Svelte wrappers. */\nexport const CLASSES = {\n root: \"relative size-full touch-manipulation overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n stage: \"absolute inset-0 opacity-0\",\n recorder:\n \"pointer-events-none absolute inset-x-0 top-[6%] z-10 flex flex-col items-center gap-3 px-4\",\n chips: \"flex min-h-12 flex-wrap items-center justify-center gap-2\",\n /**\n * An illustrated keycap: an ink outline around a darker skirt, with the\n * top face sitting on it. Held keys sink into the skirt.\n */\n chip: \"group/chip relative inline-flex h-12 min-w-12 flex-col rounded-[9px] border-[1.5px] border-(--ink) bg-(--chip-side) pb-[6px] font-sans transition-[padding,translate,opacity,scale] duration-200 ease-[cubic-bezier(0.3,1.5,0.5,1)] starting:-translate-y-1.5 starting:scale-90 starting:opacity-0 data-live:translate-y-[3px] data-live:pb-[3px]\",\n chipTop:\n \"flex flex-1 items-center justify-center rounded-[7px] border-b-[1.5px] border-(--ink) bg-(--chip) px-3 text-sm leading-none font-semibold text-(--ink)\",\n plus: \"text-sm font-semibold text-muted-foreground\",\n placeholder: \"text-sm text-muted-foreground\",\n status:\n \"flex h-4 items-center gap-1.5 text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase\",\n dot: \"size-1.5 animate-pulse rounded-full bg-red-500\",\n check: \"size-3\",\n}\n\nexport type MechKeyboardProps = {\n /** Horizontal offset, px. */\n x?: number\n /** Vertical offset, px. */\n y?: number\n /** Multiplies the fitted size. */\n scale?: number\n /** Ink and greys only; no blue caps. */\n monochrome?: boolean\n /** Real key presses and clicks press the keys. */\n interactive?: boolean\n /** Shows the shortcut being pressed above the keyboard. */\n recorder?: boolean\n /** Recorder text before the first key. */\n placeholder?: string\n /** `KeyboardEvent.code`s to pulse, e.g. `[\"MetaLeft\", \"KeyK\"]`. */\n highlight?: string[]\n /** Bottom-row legends. `\"auto\"` detects Mac. */\n platform?: MechKeyboardPlatform | \"auto\"\n /** Synthesized switch sounds. */\n sound?: boolean\n /** Rise into place with a wave across the keys on mount. */\n intro?: boolean\n /** Every key down and up. */\n onKey?: (code: string, pressed: boolean) => void\n /** Fires when every key of a chord is released. */\n onShortcut?: (shortcut: MechKeyboardShortcut) => void\n className?: string\n}\n\nconst noop = () => () => {}\n\nfunction Check() {\n return (\n \n \n \n )\n}\n\n/**\n * A 75% mechanical keyboard drawn as an isometric illustration in\n * three.js: flat tone bands, ink outlines, a volume knob. Keys spring down\n * under your keyboard (or a click; modifiers latch), and the recorder above\n * spells out the shortcut in little illustrated keycaps, onboarding-style.\n * Honors `prefers-reduced-motion`.\n */\nexport function MechKeyboard({\n x = 0,\n y = 0,\n scale = 1,\n monochrome = false,\n interactive = true,\n recorder = true,\n placeholder = DEFAULT_PLACEHOLDER,\n highlight,\n platform = \"auto\",\n sound = false,\n intro = true,\n onKey,\n onShortcut,\n className,\n}: MechKeyboardProps) {\n const rootRef = useRef(null)\n const stageRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacks = useRef({ onKey, onShortcut })\n const detected = useSyncExternalStore(\n noop,\n detectPlatform,\n (): MechKeyboardPlatform => \"pc\"\n )\n const [chord, setChord] = useState<{\n codes: string[]\n phase: MechKeyboardChordPhase\n }>({ codes: [], phase: \"idle\" })\n\n const resolvedPlatform = platform === \"auto\" ? detected : platform\n const shortcut = formatShortcut(chord.codes, resolvedPlatform)\n const highlightKey = (highlight ?? []).join(\" \")\n\n useEffect(() => {\n callbacks.current = { onKey, onShortcut }\n }, [onKey, onShortcut])\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n if (!root || !stage) return\n instanceRef.current = createMechKeyboard(root, stage, {\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight,\n platform: resolvedPlatform,\n onKey: (code, pressed) => callbacks.current.onKey?.(code, pressed),\n onShortcut: (s) => callbacks.current.onShortcut?.(s),\n onChord: (codes, phase) => setChord({ codes, phase }),\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlight: highlightKey ? highlightKey.split(\" \") : [],\n platform: resolvedPlatform,\n })\n }, [\n x,\n y,\n scale,\n monochrome,\n interactive,\n sound,\n intro,\n recorder,\n highlightKey,\n resolvedPlatform,\n ])\n\n return (\n \n
\n {recorder ? (\n
\n
\n {shortcut.keys.length === 0 ? (\n {placeholder}\n ) : (\n shortcut.keys.map((key, i) => (\n \n {i > 0 ? + : null}\n \n {key}\n \n \n ))\n )}\n
\n
\n {chord.phase === \"done\" ? (\n <>\n \n Shortcut saved\n \n ) : chord.phase === \"live\" ? (\n <>\n \n Recording\n \n ) : (\n \"Listening\"\n )}\n
\n
\n ) : null}\n
\n )\n}\n", "type": "registry:ui", "target": "components/ui/mech-keyboard.tsx" } diff --git a/registry/mech-keyboard/mech-keyboard-vanilla.ts b/registry/mech-keyboard/mech-keyboard-vanilla.ts index 686b757..b3b815c 100644 --- a/registry/mech-keyboard/mech-keyboard-vanilla.ts +++ b/registry/mech-keyboard/mech-keyboard-vanilla.ts @@ -1369,6 +1369,10 @@ export function createMechKeyboard( timers.add(id) } + // The real OS, not the `platform` legends option: it decides which + // keyboard events arrive. + const onMac = detectPlatform() === "mac" + const focused = () => document.activeElement === root || root.contains(document.activeElement) @@ -1379,7 +1383,10 @@ export function createMechKeyboard( if (event.repeat) return press(event.code) // macOS fires one event per Caps Lock toggle; never leave it stuck. - if (event.code === "CapsLock") later(() => release("CapsLock"), 160) + // Elsewhere Caps Lock sends a normal down and up, so it can be held. + if (event.code === "CapsLock" && onMac) { + later(() => release("CapsLock"), 160) + } } const onKeyUp = (event: KeyboardEvent) => { @@ -1413,7 +1420,8 @@ export function createMechKeyboard( return (hit?.object.userData.id as string | undefined) ?? null } - let pointerCode: string | null = null + /** Keys held down by each pointer, so two fingers release independently. */ + const pointers = new Map() const onPointerDown = (event: PointerEvent) => { if (!opts.interactive || event.button !== 0) return @@ -1437,18 +1445,21 @@ export function createMechKeyboard( } return } - pointerCode = code + pointers.set(event.pointerId, code) press(code) } - const onPointerUp = () => { - if (!pointerCode) return - const code = pointerCode - pointerCode = null - held.delete(code) - sound(code, false) - opts.onKey?.(code, false) - latched.clear() + const onPointerUp = (event: PointerEvent) => { + const code = pointers.get(event.pointerId) + if (!code) return + pointers.delete(event.pointerId) + if (![...pointers.values()].includes(code)) { + held.delete(code) + sound(code, false) + opts.onKey?.(code, false) + } + // Latched modifiers belong to the chord until the last finger lifts. + if (pointers.size === 0) latched.clear() paint() commit() } @@ -1581,6 +1592,9 @@ export function createMechKeyboard( normal.depthTexture?.dispose() normal.dispose() renderer.dispose() + // dispose() keeps the context until garbage collection, and browsers + // cap live contexts; give it back now. + renderer.forceContextLoss() el.remove() }, }