# Theme Switch (Notchset): prompt.md (v1.0.0)

- id: `theme-switch` · version 1.0.0 · component · free
- category: Actions
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/theme-switch.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/notchset-foundation.json
- docs: https://beautiful-ui.dev/components/theme-switch
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

One key on its plate that switches light and dark: the sun's rays retract and a mask cuts the core into a crescent, and the page sweeps to the new theme behind a 1px signal line, from the side of the change.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/notchset/theme-switch.tsx`; shared code: `lib/beautiful-ui/notchset/instrument.tsx`, `lib/beautiful-ui/notchset/root.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `NotchsetThemeScript`, `ThemeSwitch`, and every exported type.
- CSS: the install adds the notchset foundation (tokens, keyframes, motion levels) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--notchset-*` custom properties (`--notchset-check`, `--notchset-control-edge`, `--notchset-draw-from`, `--notchset-ease-bloom`, `--notchset-ease-travel`, `--notchset-fade`, `--notchset-focus-color`, `--notchset-focus-inset`, `--notchset-life-from`, `--notchset-life-ms`, `--notchset-node-blink`, `--notchset-node-bloom`, `--notchset-node-delay`, `--notchset-plate-color`, `--notchset-rise-from`, `--notchset-rule`, `--notchset-scroll`, `--notchset-signal`, `--notchset-sweep-to`). Never add Tailwind colour classes inside the component.

```tsx
import { ThemeSwitch, type ThemeName } from "@/components/ui/notchset/theme-switch";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `ThemeSwitch` | `theme-switch` | The key (className and ref land here). |
| `Plate` | `theme-switch-plated` | The key's plate. |

Style a part with `[data-slot="<slot>"]` selectors or its `className`; keep the attributes when editing.

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `SoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/notchset-sound.json`, import from `@/components/ui/notchset/sound-provider`); `useSound()` gives `muted` and `setMuted` for a mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/notchset/theme-switch.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- theme switch, dark mode toggle, light dark, color mode, theme toggle, view transition, Notchset
- The light / dark control in a header or settings

### Not when
- Three-way light / dark / system choices: use a Select or Button Group

## Mistakes
- With next-themes, control it: pass theme and onThemeChange
- Add the Notchset foundation once (installed with it): the sweep's CSS lives there

## Usage

```tsx
"use client";

import * as React from "react";
import { ThemeSwitch, type ThemeName } from "@/components/ui/notchset/theme-switch";

/** With next-themes: const { resolvedTheme, setTheme } = useTheme(), and pass those instead. */
export function ThemeToggle() {
  const [theme, setTheme] = React.useState<ThemeName>("light");
  React.useEffect(() => {
    document.documentElement.classList.toggle("dark", theme === "dark");
  }, [theme]);
  return <ThemeSwitch theme={theme} onThemeChange={setTheme} />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `theme / onThemeChange` | `"light" \| "dark" / (theme) => void` |  | Controlled, e.g. with next-themes' resolvedTheme and setTheme. |
| `defaultTheme` | `"light" \| "dark"` |  | Uncontrolled start. Without either, it reads and toggles the dark class on <html> and saves the choice under localStorage "theme". |
| `label` | `string` |  | The key's label. Default "SWITCH THEME". |
| `iconOnly` | `boolean` |  | A 40×40 key. |
| `sweep` | `boolean` |  | The View Transitions sweep. Default true; instant without support or under reduced motion. |
| `labels` | `{ toDark?, toLight? }` |  | The accessible names. |

Full docs: https://beautiful-ui.dev/components/theme-switch

## Customising
- Colours: the component reads your shadcn tokens (`--background`, `--foreground`, `--border` …), refined by the `--notchset-*` tokens. The signal colour is `--notchset-signal` (it falls back to `--destructive`). Set tokens on `:root`, or on any container to retheme one area.
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Enter / Space | Switch theme |

## Performance

- The sweep uses the View Transitions API when the browser has it and swaps instantly when it doesn't.
- The theme script runs before paint, so there is no flash of the wrong theme.

## Responsive

- A fixed three-cell switch; it never wraps.
- 44px cells on touch screens.

## Motion inventory

| Interaction | What moves |
|---|---|
| To dark | Rays scale to .3 and fade (22ms apart); the core grows to r 7.5; the mask slides in to cut the crescent; the glyph tilts to −28° |
| To light | The mask slides out, the core shrinks, the rays spring back with an overshoot, 28ms apart |
| Sweep | The new theme is revealed by clip-path from the side of the change over 580ms cubic-bezier(.7,0,.25,1), behind a 1px signal line that fades over the last 100ms |

## Accessibility contract (preserve when editing)
- The name says what pressing does: Switch to dark theme / Switch to light theme
- Reduced motion swaps instantly, with no sweep
- The glyph is decorative (aria-hidden)

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/theme-switch.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/theme-switch.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/theme-switch`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/notchset/theme-switch.tsx

```tsx
"use client";

/**
 * Theme Switch (Notchset) v1.0.0 · Beautiful UI, by Chimpy (https://x.com/stochastichimp)
 * Docs: https://beautiful-ui.dev/components/theme-switch · Agent prompt: https://beautiful-ui.dev/md/theme-switch.md
 * MIT licensed: free to use, change and share.
 */

import * as React from "react";
import { flushSync } from "react-dom";
import { cn } from "@/lib/utils";
import { motionOff, OWN_SOUND, playCue, NOTCHSET_ROOT, useComposedRefs } from "@/lib/beautiful-ui/notchset/instrument";

/*
 * Notchset Theme Switch: one key on its plate, [glyph] SWITCH THEME. The sun's rays retract and a
 * mask cuts the core into a crescent (and back), and the page sweeps to the new theme behind a 1px
 * signal line, in the direction of the change (View Transitions; an instant swap without them or
 * under reduced motion).
 *
 * Controlled with `theme` and `onThemeChange` (e.g. next-themes' resolvedTheme / setTheme). On its
 * own it mirrors and toggles the `dark` class on <html> and remembers the choice in localStorage under
 * "theme" (next-themes' key); put <NotchsetThemeScript /> in <head> so the saved choice (else the
 * system preference) is applied before first paint.
 */

export type ThemeName = "light" | "dark";

export type ThemeSwitchProps = Omit<React.ComponentProps<"button">, "children" | "onClick"> & {
  theme?: ThemeName;
  defaultTheme?: ThemeName;
  onThemeChange?: (theme: ThemeName) => void;
  /** The key's label. Default "SWITCH THEME". */
  label?: string;
  /** A 40×40 key; the label moves into aria-label. */
  iconOnly?: boolean;
  /** The page sweep. Default true. */
  sweep?: boolean;
  /** Accessible names. */
  labels?: { toDark?: string; toLight?: string };
};

const STORAGE_KEY = "theme";

function readDocumentTheme(): ThemeName {
  if (typeof document === "undefined") return "light";
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
}

function subscribeDocumentTheme(onChange: () => void) {
  const mo = new MutationObserver(onChange);
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
  return () => mo.disconnect();
}

function applyDocumentTheme(next: ThemeName) {
  const root = document.documentElement;
  root.classList.toggle("dark", next === "dark");
  root.style.colorScheme = next;
  try {
    localStorage.setItem(STORAGE_KEY, next);
  } catch {
    // Private mode or blocked storage: the switch still works for this page.
  }
}

/** The sweep in flight: a newer switch applies instantly instead of racing it. */
let sweeping = 0;

/** Runs `change` behind the sweep: View Transitions with a clip from the side of the change. */
function withSweep(next: ThemeName, change: () => void, el: Element | null) {
  const doc = document as Document & { startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> } };
  if (!doc.startViewTransition || motionOff(el) || sweeping) return change();
  const root = document.documentElement;
  const token = ++sweeping;
  root.dataset.notchsetSweep = next === "dark" ? "to-dark" : "to-light";
  // The 1px signal line rides the clip edge: its own transition layer, present only in the new state.
  const line = document.createElement("div");
  line.setAttribute("aria-hidden", "true");
  line.dataset.notchsetSweepLine = "";
  const transition = doc.startViewTransition(() => {
    // Commit React's update (a controlled theme) before the new snapshot is taken.
    flushSync(change);
    document.body.appendChild(line);
  });
  transition.ready.catch(() => {});
  transition.finished
    .catch(() => {})
    .finally(() => {
      line.remove();
      if (sweeping === token) {
        sweeping = 0;
        delete root.dataset.notchsetSweep;
      }
    });
}

/**
 * For <head>: applies the saved theme (localStorage "theme"), else the system preference, before
 * first paint, so the page and an uncontrolled ThemeSwitch agree from the start.
 */
function NotchsetThemeScript({ storageKey = STORAGE_KEY }: { storageKey?: string }) {
  const code = `(function(){try{var t=localStorage.getItem(${JSON.stringify(storageKey)});if(t!=="dark"&&t!=="light")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";var r=document.documentElement;r.classList.toggle("dark",t==="dark");r.style.colorScheme=t}catch(e){}})()`;
  return <script dangerouslySetInnerHTML={{ __html: code }} />;
}

function ThemeSwitch({ theme: themeProp, defaultTheme, onThemeChange, label = "SWITCH THEME", iconOnly = false, sweep = true, labels, className, disabled, ref, ...props }: ThemeSwitchProps) {
  const [spin, setSpin] = React.useState(0);
  const keyRef = React.useRef<HTMLButtonElement>(null);
  const refs = useComposedRefs<HTMLButtonElement>(ref, keyRef);
  const controlled = themeProp !== undefined;
  // On its own, the page's theme is the one source of truth (defaultTheme is what the server renders).
  const page = React.useSyncExternalStore(subscribeDocumentTheme, readDocumentTheme, () => defaultTheme ?? "light");
  const theme = themeProp ?? page;
  const dark = theme === "dark";

  const toggle = () => {
    const next: ThemeName = dark ? "light" : "dark";
    playCue(keyRef.current, "latch");
    setSpin((s) => s + 1);
    const change = () => {
      if (!controlled) applyDocumentTheme(next);
      onThemeChange?.(next);
    };
    if (sweep && typeof window !== "undefined") withSweep(next, change, keyRef.current);
    else change();
  };

  const name = dark ? (labels?.toLight ?? "Switch to light theme") : (labels?.toDark ?? "Switch to dark theme");
  const rays = Array.from({ length: 8 }, (_, i) => {
    const a = (i * Math.PI) / 4;
    const delay = dark ? i * 22 : 160 + i * 28;
    return {
      x1: 12 + Math.cos(a) * 7.4,
      y1: 12 + Math.sin(a) * 7.4,
      x2: 12 + Math.cos(a) * 10.2,
      y2: 12 + Math.sin(a) * 10.2,
      transition: dark
        ? `transform 260ms var(--notchset-ease-travel) ${delay}ms, opacity 160ms linear ${delay + 80}ms`
        : `transform 380ms cubic-bezier(.34,1.8,.5,1) ${delay}ms, opacity 120ms linear ${delay}ms`,
    };
  });
  const maskId = `notchset-theme-mask-${React.useId().replace(/:/g, "")}`;

  return (
    <span {...NOTCHSET_ROOT} data-slot="theme-switch-plated" className="relative inline-flex [--notchset-plate-color:var(--notchset-control-edge,var(--input))] has-[:hover]:[--notchset-plate-color:var(--foreground)]">
      <span aria-hidden data-slot="plate" className={`notchset-plate`} />
      <button {...OWN_SOUND}
        {...props}
        ref={refs}
        type="button"
        data-slot="theme-switch"
        data-theme={theme}
        aria-label={name}
        disabled={disabled}
        onClick={toggle}
        className={cn(
          `notchset-focus`,
          "flex h-10 pointer-coarse:h-11 cursor-pointer items-center gap-2.5 border border-solid border-[var(--notchset-control-edge,var(--input))] bg-background font-mono text-[12px] font-medium tracking-[0.07em] text-foreground uppercase select-none",
          "transition-[transform,background-color,border-color,color] duration-[var(--notchset-key-up),300ms,300ms,300ms] ease-[var(--notchset-ease-key-up),linear,linear,linear] delay-[0ms,200ms,200ms,200ms]",
          "hover:border-foreground hover:bg-accent active:translate-x-[2px] active:translate-y-[2px] active:duration-[var(--notchset-key-down)] active:ease-linear",
          "disabled:cursor-not-allowed disabled:opacity-40",
          iconOnly ? "w-10 justify-center p-0" : "pr-4 pl-[13px]",
          className,
        )}
      >
        <svg
          aria-hidden
          width="18"
          height="18"
          viewBox="0 0 24 24"
          className="flex-none overflow-visible motion-reduce:!transition-none"
          style={{ transform: `rotate(${dark ? -28 : spin * 90}deg)`, transition: "transform 640ms cubic-bezier(.34,1.4,.5,1)" }}
        >
          <defs>
            <mask id={maskId}>
              <rect x="-4" y="-4" width="32" height="32" fill="white" />
              <circle
                r="5.5"
                fill="black"
                className="motion-reduce:!transition-none"
                style={{
                  cx: dark ? "16.5px" : "23px",
                  cy: dark ? "8px" : "1px",
                  transition: dark ? "cx 520ms var(--notchset-ease-unfold) 160ms, cy 520ms var(--notchset-ease-unfold) 160ms" : "cx 300ms var(--notchset-ease-travel), cy 300ms var(--notchset-ease-travel)",
                }}
              />
            </mask>
          </defs>
          <circle
            cx="12"
            cy="12"
            fill="currentColor"
            mask={`url(#${maskId})`}
            className="motion-reduce:!transition-none"
            style={{ r: dark ? "7.5px" : "4px", transition: dark ? "r 460ms cubic-bezier(.34,1.5,.5,1) 120ms" : "r 420ms cubic-bezier(.34,1.5,.5,1)" }}
          />
          {rays.map((ray, i) => (
            <line
              key={i}
              x1={ray.x1.toFixed(2)}
              y1={ray.y1.toFixed(2)}
              x2={ray.x2.toFixed(2)}
              y2={ray.y2.toFixed(2)}
              stroke="currentColor"
              strokeWidth="1.7"
              strokeLinecap="square"
              className="motion-reduce:!transition-none"
              style={{ transform: `scale(${dark ? 0.3 : 1})`, transformOrigin: "12px 12px", opacity: dark ? 0 : 1, transition: ray.transition }}
            />
          ))}
        </svg>
        {!iconOnly && <span>{label}</span>}
      </button>
    </span>
  );
}

export { NotchsetThemeScript, ThemeSwitch };
```

### lib/beautiful-ui/notchset/instrument.tsx

```tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Notchset instrument parts, shared by every member of a pack: the node (a round light that reports
 * state), the legend printed above a control, the plate a key presses onto, the traced check, the
 * five-tick scanner, and the glyph flip that lands a changed label left to right. Nodes and checks
 * are inline SVG (round at every DPR); the CSS lives in the Notchset foundation. Also: the one motion
 * policy (reduced motion or a data-motion="off" ancestor), ref composition, the portal container
 * and the sound attributes every Notchset root carries.
 */

// ---------------------------------------------------------------------------------------------
// Motion, refs, portals, sound
// ---------------------------------------------------------------------------------------------

/** True under prefers-reduced-motion or inside a data-motion="off" scope. Checked when an effect runs. */
/**
 * Whether motion is off for an element, kept live: it follows the OS reduced-motion setting and any
 * data-motion attribute change above it, so a preference switched mid-playback stops decoration at once.
 */
export function useMotionOff(ref: React.RefObject<Element | null>) {
  const [off, setOff] = React.useState(false);
  React.useEffect(() => {
    const read = () => setOff(motionOff(ref.current));
    const r = requestAnimationFrame(read);
    const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
    mq?.addEventListener?.("change", read);
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"], subtree: true });
    return () => {
      cancelAnimationFrame(r);
      mq?.removeEventListener?.("change", read);
      mo.disconnect();
    };
  }, [ref]);
  return off;
}

/**
 * The microphone, or a rejection after `timeoutMs` (a prompt left open, a denied permission). A stream
 * that arrives after the timeout is stopped at once, so a late "Allow" never leaves the mic on.
 */
export function requestMic(timeoutMs = 5000): Promise<MediaStream> {
  if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) return Promise.reject(new Error("no mic"));
  const ask = navigator.mediaDevices.getUserMedia({ audio: true });
  return new Promise((resolve, reject) => {
    let late = false;
    const t = setTimeout(() => {
      late = true;
      reject(new Error("timeout"));
    }, timeoutMs);
    ask.then(
      (stream) => {
        clearTimeout(t);
        if (late) stream.getTracks().forEach((track) => track.stop());
        else resolve(stream);
      },
      (err) => {
        clearTimeout(t);
        reject(err);
      },
    );
  });
}

export function motionOff(el: Element | null | undefined) {
  if (typeof window === "undefined") return true;
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return true;
  return Boolean(el?.closest('[data-motion="off"]'));
}

function assignRef<T>(ref: React.Ref<T> | undefined, node: T | null): () => void {
  if (typeof ref === "function") {
    const cleanup = ref(node);
    return typeof cleanup === "function" ? cleanup : () => ref(null);
  }
  if (ref) (ref as React.RefObject<T | null>).current = node;
  return () => {
    if (ref) (ref as React.RefObject<T | null>).current = null;
  };
}

/** Composes two refs, keeping React 19 ref cleanups. */
export function useComposedRefs<T>(a: React.Ref<T> | undefined, b: React.Ref<T> | undefined): React.RefCallback<T> {
  return React.useCallback(
    (node: T | null) => {
      const release = [assignRef(a, node), assignRef(b, node)];
      return () => release.forEach((f) => f());
    },
    [a, b],
  );
}

/** Runs ours after theirs, unless theirs called preventDefault. */
export function composeHandler<E extends { defaultPrevented: boolean }>(theirs: ((e: E) => void) | undefined, ours: (e: E) => void) {
  return (e: E) => {
    theirs?.(e);
    if (!e.defaultPrevented) ours(e);
  };
}

/** Keeps the latest value in a ref, for callbacks read inside timers and animation frames. */
export function useLatest<T>(value: T) {
  const ref = React.useRef(value);
  React.useLayoutEffect(() => {
    ref.current = value;
  });
  return ref;
}

/*
 * Where Notchset overlays (menus, popovers, tooltips) portal to. Unset, document.body, as shadcn's
 * do. Provide an element to keep them inside a scoped theme: a preview, a widget, a shadow root.
 */
export const NotchsetPortalContext = React.createContext<HTMLElement | null>(null);
export function useNotchsetPortal(): HTMLElement | undefined {
  return React.useContext(NotchsetPortalContext) ?? undefined;
}

export { NOTCHSET_ROOT, OWN_SOUND } from "@/lib/beautiful-ui/notchset/root";

// ---------------------------------------------------------------------------------------------
// Parts
// ---------------------------------------------------------------------------------------------

export type NodeState = "off" | "on" | "live" | "signal";

/** The node: a 9×9 ring whose dot blooms on (`on`), blinks (`live`) or turns signal. */
function Node({ state = "off", delay, className, style, ...props }: Omit<React.ComponentProps<"svg">, "children"> & { state?: NodeState; delay?: number }) {
  return (
    <svg
      aria-hidden
      data-slot="indicator"
      data-state={state}
      width="9"
      height="9"
      viewBox="0 0 9 9"
      className={cn(`notchset-node`, className)}
      style={delay ? ({ "--notchset-node-delay": `${delay}ms`, ...style } as React.CSSProperties) : style}
      {...props}
    >
      <circle data-slot="indicator-ring" cx="4.5" cy="4.5" r="3.25" fill="none" strokeWidth="1" />
      <circle data-slot="indicator-dot" cx="4.5" cy="4.5" />
    </svg>
  );
}

/** A small solid round mark (the node where a leader meets a popup, a pointer): SVG, round at every DPR. */
function Dot({ size = 7, className, style }: { size?: number; className?: string; style?: React.CSSProperties }) {
  return (
    <svg aria-hidden width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={cn("pointer-events-none absolute overflow-visible", className)} style={style}>
      <circle cx={size / 2} cy={size / 2} r={size / 2} fill="currentColor" />
    </svg>
  );
}

/** The legend: a mono label above a control, with an optional node (or any trailing reading). */
function Legend({ label, node, children, className, ...props }: React.ComponentProps<"div"> & { label?: React.ReactNode; node?: NodeState }) {
  return (
    <div data-slot="legend" className={cn(`notchset-legend`, className)} {...props}>
      <span data-slot="legend-label">{label}</span>
      {(children != null || node) && (
        <span data-slot="legend-reading" className="flex items-center gap-2">
          {children}
          {node && <Node state={node} />}
        </span>
      )}
    </div>
  );
}

/** A key on its plate: the plate is an offset frame behind the child; `--notchset-plate-color` tints it. */
function Plated({ className, children, ...props }: React.ComponentProps<"span">) {
  return (
    <span data-slot="plated" className={cn("relative inline-flex", className)} {...props}>
      <span aria-hidden data-slot="plate" className={`notchset-plate`} />
      {children}
    </span>
  );
}

/**
 * The success check. Mounted, it traces itself in over --notchset-check (instant under reduced
 * motion); pass `drawn` to drive the trace yourself.
 */
function Check({ drawn, size = 16 }: { drawn?: boolean; size?: number }) {
  return (
    <svg aria-hidden data-slot="check" width={size} height={size} viewBox="0 0 24 24">
      <path
        d="M4 12.5 L9.5 18 L20 6"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="square"
        pathLength={1}
        strokeDasharray={1}
        className={drawn === undefined ? `notchset-check-draw` : undefined}
        style={drawn === undefined ? undefined : { strokeDashoffset: drawn ? 0 : 1, transition: "stroke-dashoffset var(--notchset-check) var(--notchset-ease-travel)" }}
      />
    </svg>
  );
}

/** The scanner: five 1px ticks fading in turn while something works (static, centre lit, when motion is off). */
function Scanner({ height = 12 }: { height?: number }) {
  return (
    <span aria-hidden data-slot="scanner" className={cn(`notchset-scanner`, "inline-flex items-end gap-[2px]")} style={{ height }}>
      {[0.82, 0.66, 1, 0.66, 0.82].map((h, i) => (
        <span key={i} className="w-px bg-current" style={{ height: height * h, animationDelay: `${i * 140 - 700}ms` }} />
      ))}
    </span>
  );
}

// ---------------------------------------------------------------------------------------------
// Glyph flip: each character cycles through glyphs and lands at 110 + i·14ms, left to right
// ---------------------------------------------------------------------------------------------

const GLYPHS = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789#/+=";
const STILL = new Set([" ", "·", "/", ".", ":"]);

export function scramble(text: string, elapsed: number) {
  let out = "";
  for (let i = 0; i < text.length; i++) {
    const ch = text[i]!;
    out += STILL.has(ch) || elapsed >= 110 + i * 14 ? ch : GLYPHS[(Math.floor(elapsed / 36) * 5 + i * 11) % GLYPHS.length];
  }
  return out;
}

/**
 * The text to show for `text`: when it changes, a few frames of glyphs that land left to right.
 * Runs on requestAnimationFrame only while a flip is active; the settled text is never state. Off
 * under reduced motion, inside data-motion="off" (pass the element) or with `off`.
 */
export function useGlyphFlip(text: string, { off = false, el }: { off?: boolean; el?: React.RefObject<Element | null> } = {}) {
  const [frame, setFrame] = React.useState<{ for: string; shown: string } | null>(null);
  const first = React.useRef(true);
  React.useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    if (off || motionOff(el?.current)) return;
    let raf = 0;
    const t0 = performance.now();
    const end = 110 + text.length * 14 + 40;
    const step = (now: number) => {
      const elapsed = now - t0;
      if (elapsed >= end) return setFrame(null);
      setFrame({ for: text, shown: scramble(text, elapsed) });
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [text, off, el]);
  return frame && frame.for === text ? frame.shown : text;
}

/** A label that flips when its text changes; screen readers read the final text only. */
function FlipText({ children, off, className, ...props }: Omit<React.ComponentProps<"span">, "children"> & { children: string; off?: boolean }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const shown = useGlyphFlip(children, { off, el: ref });
  return (
    <span ref={ref} data-slot="flip-text" className={cn("relative whitespace-nowrap", className)} {...props}>
      <span aria-hidden>{shown}</span>
      <span className="sr-only">{children}</span>
    </span>
  );
}

// ---------------------------------------------------------------------------------------------
// Sound: Notchset components announce cues; the page's beautiful-ui-sound layer (opt-in) plays them.
// ---------------------------------------------------------------------------------------------

/** The Notchset cues, in the shared sound layer's names (played in the mechanical voice). */
export const NOTCHSET_CUES = { tap: "tap", latch: "lock", signal: "destructive", confirm: "halt", done: "success", tick: "tick" } as const;
export type NotchsetCue = keyof typeof NOTCHSET_CUES;

export function playCue(el: Element | null, cue: NotchsetCue) {
  if (!el || typeof CustomEvent === "undefined" || el.closest('[data-sound="off"]')) return;
  el.dispatchEvent(new CustomEvent("beautiful-ui:sound", { bubbles: true, detail: { cue: NOTCHSET_CUES[cue] } }));
}

// ---------------------------------------------------------------------------------------------
// Shortcuts: "⌘↵" → "Meta+Enter Control+Enter" (aria-keyshortcuts)
// ---------------------------------------------------------------------------------------------

const KEY_NAMES: Record<string, string> = { "⌘": "Meta", "⌃": "Control", "⌥": "Alt", "⇧": "Shift", "↵": "Enter", "⏎": "Enter", "⎋": "Escape", "⌫": "Backspace", "⇥": "Tab", "␣": "Space" };

export function kbdToShortcut(kbd: string | undefined): string | undefined {
  if (!kbd) return undefined;
  const parts = [...kbd.replace(/\s+/g, "")].map((c) => KEY_NAMES[c] ?? c.toUpperCase());
  if (!parts.length) return undefined;
  const combo = parts.join("+");
  // ⌘ on a Mac is Ctrl elsewhere: announce both.
  return combo.includes("Meta") ? `${combo} ${combo.replace("Meta", "Control")}` : combo;
}

/**
 * A number that rolls: each digit is a 0–9 strip; the ones digit moves first and each digit to its left
 * 40ms later (620ms, a small overshoot). `delay` offsets the whole number (rows 60ms apart). Non-digits
 * (",", "$", "%") sit still. Screen readers get the plain value.
 */
function RollingNumber({ value, height = 32, delay = 0, className }: { value: string | number; height?: number; delay?: number; className?: string }) {
  const text = String(value);
  const digits = text.split("");
  const lastDigit = digits.reduce((n, d, i) => (/\d/.test(d) ? i : n), -1);
  return (
    <span className={cn("relative inline-flex overflow-hidden tabular-nums", className)} style={{ height, lineHeight: `${height}px` }}>
      <span className="sr-only">{text}</span>
      <span aria-hidden className="flex">
        {digits.map((d, i) =>
          /\d/.test(d) ? (
            <span
              key={digits.length - i}
              className="flex flex-col transition-transform duration-[620ms] ease-[cubic-bezier(.34,1.15,.5,1)]"
              style={{ transform: `translateY(${-Number(d) * height}px)`, transitionDelay: `${delay + (lastDigit - i) * 40}ms` }}
            >
              {Array.from({ length: 10 }, (_, n) => (
                <span key={n} style={{ height }}>
                  {n}
                </span>
              ))}
            </span>
          ) : (
            <span key={`s${digits.length - i}`}>{d}</span>
          ),
        )}
      </span>
    </span>
  );
}

export { Check, Dot, FlipText, Legend, Node, Plated, RollingNumber, Scanner };
```

### lib/beautiful-ui/notchset/root.ts

```tsx
/*
 * Notchset's root attributes, server-safe (no client directive), so server components can spread
 * them too. Every Notchset root (and portalled popup) carries data-notchset (the motion policy's
 * scope), plays its own cues in the mechanical voice, and keeps the page's click layer out.
 */
export const NOTCHSET_ROOT = { "data-notchset": "", "data-sound": "none", "data-sound-voice": "analogue" } as const;

/** On every interactive element: it plays its own cues, so the page's click layer adds no tap. */
export const OWN_SOUND = { "data-sound": "none" } as const;
```

The notchset foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/notchset-foundation.json.
