# Button (Notchset): prompt.md (v1.0.0)

- id: `button` · version 1.0.0 · component · free
- category: Actions
- build: Base UI (this item also ships a Radix build)
- install (this build): `npx shadcn@latest add https://notchset.dev/r/button.json`
- npm dependencies: @base-ui/react@^1, class-variance-authority@^0.7
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json
- docs: https://notchset.dev/components/button
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

shadcn's Button in Notchset: square, mono caps, a tick ruler on hover, a five-tick scanner while it works and a check that traces itself when it's done, at a width that never moves. The Plate variant presses onto its plate; the link draws its rule from the left.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@base-ui/react@^1`, `class-variance-authority@^0.7` (Base UI build); `class-variance-authority@^0.7`, `radix-ui@^1` (Radix build).
- Files: `components/ui/notchset/button.tsx`, `components/ui/notchset/button-client.tsx`; shared code: `lib/beautiful-ui/notchset/button-variants.ts`, `lib/beautiful-ui/notchset/button.tsx`, `lib/beautiful-ui/notchset/instrument.tsx`, `lib/beautiful-ui/notchset/root.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`.
- Builds: separate Base UI and Radix files. 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: `Button`, `buttonVariants`, `ButtonClient`, 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-glide`, `--notchset-ease-key-down`, `--notchset-ease-key-up`, `--notchset-ease-travel`, `--notchset-fade`, `--notchset-focus-color`, `--notchset-focus-inset`, `--notchset-grow-to`, `--notchset-key-down`, `--notchset-key-up`, `--notchset-life-from`, `--notchset-life-ms`, `--notchset-node-blink`, `--notchset-node-bloom`, `--notchset-node-delay`, `--notchset-plate-color`, `--notchset-rise-from`, `--notchset-rule`, `--notchset-scan-to`, `--notchset-scroll`, `--notchset-sheet-from`, `--notchset-signal`, `--notchset-sweep-to`, `--notchset-travel`). Never add Tailwind colour classes inside the component.

```tsx
import { Button, buttonVariants } from "@/components/ui/notchset/button";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Button` | `button` | The button. |
| `Icon` | `button-icon` | The icon, scanner or check. |
| `Ruler` | `button-ruler` | The hover ruler. |

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://notchset.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/button.tsx`, `components/ui/notchset/button-client.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
- button, action, run, deploy, async button, loading button, shadcn button, link button, Notchset
- Every action in a Notchset interface
- A drop-in for shadcn's Button: same exports, variants, sizes and data-slot
- Async work: return a Promise from onClick and it runs loading → done → idle

### Not when
- Starting and stopping a run: use Run Button
- Destroying something that matters: use Confirm Button

## Mistakes
- Use one destructive (signal) button per view, for stop and destroy
- Install the Notchset theme for the exact look; without it the button uses your shadcn tokens

## Usage

```tsx
"use client";

import { Button, buttonVariants } from "@/components/ui/notchset/button";

export function EvalActions({ runEval, deploy }: { runEval: () => Promise<void>; deploy: () => void }) {
  return (
    <div className="flex flex-wrap items-center gap-3">
      {/* Async onClick: the button shows its loading and success states on its own. */}
      <Button icon="play" kbd="⌘↵" loadingLabel="Running" successLabel="Done" onClick={runEval}>
        Run eval
      </Button>
      <Button variant="plate" icon="arrow" onClick={deploy}>
        Deploy
      </Button>
      <a className={buttonVariants({ variant: "link" })} href="/docs">
        Docs
      </a>
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `variant` | `"default" \| "destructive" \| "outline" \| "secondary" \| "ghost" \| "link" \| "plate" \| "signal"` |  | shadcn's six, plus Plate (a key that rests on a 1px plate and presses onto it) and Signal (outlined in signal, for destructive actions). |
| `size` | `"default" \| "xs" \| "sm" \| "lg" \| "icon" \| "icon-xs" \| "icon-sm" \| "icon-lg"` |  | 32 / 24 / 28 / 36px tall, as shadcn, with 12px mono labels (13px on lg); icon sizes are squares. At least 44px tall on touch screens. |
| `icon` | `"play" \| "arrow" \| "plus" \| "trace" \| "stop" \| "copy" \| "external" \| ReactNode` |  | A Notchset glyph or your own svg; the scanner and check take its place. |
| `iconPosition` | `"left" \| "right"` |  | Default left. |
| `kbd` | `string` |  | A shortcut hint in a box; sets aria-keyshortcuts. |
| `loading / success` | `boolean` |  | Controlled states. |
| `loadingLabel / successLabel` | `string` |  | The words while working and when done; the widest label sets the width. |
| `successHold` | `number` |  | How long an automatic success shows (ms). Default 1400. |
| `flat` | `boolean` |  | No hover ruler, and no plate on Plate. |
| `onClick` | `(event) => unknown` |  | Return a Promise and the button runs itself. |
| `confirm` | `string` |  | Two steps: the first press arms it with this label in signal; the second runs onClick. Disarms after 3s or on Esc. |
| `hold / onHoldComplete` | `number / () => void` |  | Press and hold (ms): the key sinks onto its plate while a fill grows, then runs onHoldComplete. Release early and nothing happens. |
| `undoMs / onUndo` | `number / () => void` |  | After a hold, the key reads UNDO · 5S and counts down; pressing it calls onUndo. Default 5000; 0 for none. |
| `pressed` | `boolean` |  | A toggle: filled with ink while true (aria-pressed). |
| `count` | `number` |  | A number after the label that rolls when it changes. |
| `progress` | `number` |  | 0–1: a fill grows inside the key and a rolling percentage follows the label. |
| `disabledReason` | `string` |  | Disabled with a reason: stays focusable, shows why in a tag above it on hover and focus, ignores presses. |
| `render (Base UI) / asChild (Radix)` | `as shadcn` |  | Render as a link or your own element. |

Full docs: https://notchset.dev/components/button

## 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://notchset.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Enter / Space | Activate |

## Performance

- One native button; press, hover and the link rule are CSS transitions on transform, background and scale.
- The scanner and check draw only while loading or done; an idle button runs no JavaScript.
- Motion off (reduced motion or data-motion="off") lands every transition at once.

## Responsive

- Fixed heights per size (24, 28, 32, 36px), labels 12–13px; icon sizes are squares.
- On touch screens every size grows to at least 44px tall.

## Motion inventory

| Interaction | What moves |
|---|---|
| Hover | The fill changes and a 4px tick ruler fades in (not on touch, flat or link) |
| Press | Plate: the key travels 2px onto its plate in 40ms and springs back in 200ms |
| Loading | The scanner: five ticks fading in turn, 700ms |
| Done | The check traces in over 260ms; it holds 1400ms, then idle |
| Link | Ink draws across the rule from the left in 280ms (retracting to the right); the arrow steps 4px |

## Accessibility contract (preserve when editing)
- A native button through Base UI or Radix, as shadcn builds it
- aria-busy while loading; only the current label is read (the others are hidden)
- kbd sets aria-keyshortcuts (⌘ also as Ctrl)
- Focus shows crop marks, keyboard only

## Install

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

```bash
npx shadcn@latest add https://notchset.dev/r/button.json
```

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

```bash
npx shadcn@latest add https://notchset.dev/r/radix-nova/button.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/button`: the CLI picks the build from your style.

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

## Source (Base UI build)

### components/ui/notchset/button.tsx

```tsx
/**
 * Button (Notchset) v1.0.0 · Notchset
 * Docs: https://notchset.dev/components/button
 * MIT licensed: free to use, change and share.
 */
import { ButtonClient, type ButtonProps } from "@/components/ui/notchset/button-client";
import { buttonVariants } from "@/lib/beautiful-ui/notchset/button-variants";

/*
 * Notchset Button (Base UI build). Server-safe like shadcn's: buttonVariants() works anywhere, and the
 * interactive part (phases, sound, the plate) lives in button-client.tsx.
 */

function Button(props: ButtonProps) {
  return <ButtonClient {...props} />;
}

export { Button, buttonVariants };
export type { ButtonProps };
```

### components/ui/notchset/button-client.tsx

```tsx
"use client";

/**
 * Button (Notchset) v1.0.0 · Notchset
 * Docs: https://notchset.dev/components/button
 * MIT licensed: free to use, change and share.
 */

import * as React from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/lib/beautiful-ui/notchset/button-variants";
import { NOTCHSET_ROOT, kbdToShortcut, playCue, useComposedRefs } from "@/lib/beautiful-ui/notchset/instrument";
import { ButtonContent, ButtonPlate, chain, iconSize, useButtonModes, useButtonPhase, type ButtonExtras, type ButtonSize, type ButtonVariant } from "@/lib/beautiful-ui/notchset/button";

/*
 * Notchset Button (Base UI build). shadcn's Button, same exports, variants, sizes and data-slot, in
 * Notchset: square, mono caps, a tick ruler on hover, the scanner while it works, a traced check when
 * done, and the Plate variant that presses onto its plate. Return a Promise from onClick and it
 * runs loading → done → idle by itself. Use `render` for links, as in shadcn's Base UI build.
 */

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  ButtonExtras & {
    variant?: ButtonVariant;
    size?: ButtonSize;
    onClick?: (event: React.MouseEvent<HTMLButtonElement>) => unknown;
  };

function ButtonClient({ className, variant = "default", size = "default", icon, iconPosition, kbd, loading, success, loadingLabel, successLabel, successHold, flat, pressed, count, progress, disabledReason, confirm, hold, holdingLabel, onHoldComplete, undoMs, onUndo, onClick, onPointerDown, onPointerUp, onPointerCancel, onLostPointerCapture, onKeyDown, onKeyUp, onBlur, children, ref, ...props }: ButtonProps) {
  const own = React.useRef<HTMLButtonElement>(null);
  const refs = useComposedRefs<HTMLButtonElement>(ref as React.Ref<HTMLButtonElement>, own);
  const [phase, run] = useButtonPhase({ loading, success, successHold });
  const busy = phase === "loading";
  const modes = useButtonModes({ confirm, hold, onHoldComplete, undoMs, onUndo, disabledReason, disabled: Boolean(props.disabled) });
  const reasonId = React.useId();
  const button = (
    <ButtonPrimitive
      {...NOTCHSET_ROOT}
      ref={refs}
      data-slot="button"
      data-variant={variant}
      data-size={size}
      data-state={phase}
      data-mode={modes.mode === "idle" ? undefined : modes.mode}
      aria-busy={busy || undefined}
      aria-pressed={pressed}
      aria-disabled={disabledReason ? true : undefined}
      aria-describedby={disabledReason ? reasonId : undefined}
      onPointerDown={chain(onPointerDown, modes.handlers.onPointerDown)}
      onPointerUp={chain(onPointerUp, modes.handlers.onPointerUp)}
      onPointerCancel={chain(onPointerCancel, modes.handlers.onPointerCancel)}
      onLostPointerCapture={chain(onLostPointerCapture, modes.handlers.onLostPointerCapture)}
      onKeyDown={chain(onKeyDown, modes.handlers.onKeyDown)}
      onKeyUp={chain(onKeyUp, modes.handlers.onKeyUp)}
      onBlur={chain(onBlur, modes.handlers.onBlur)}
      aria-keyshortcuts={kbdToShortcut(kbd)}
      onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
        // While it works, a press does nothing: no second run and no form submit.
        if (busy) {
          e.preventDefault();
          return;
        }
        // Arming, undo and a disabled reason take the press; the action runs on the second press or the hold.
        if (modes.consume(e)) return;
        playCue(own.current, variant === "destructive" ? "signal" : variant === "plate" ? "latch" : variant === "ghost" || variant === "link" ? "tick" : "tap");
        run(own.current, onClick?.(e));
      }}
      className={(state) => cn(`notchset-focus`, buttonVariants({ variant, size }), typeof className === "function" ? className(state) : className)}
      {...props}
    >
      <ButtonContent phase={phase} icon={icon} iconPosition={iconPosition} kbd={kbd} loadingLabel={loadingLabel} successLabel={successLabel} ruler={!flat && variant !== "link"} iconOnly={iconSize(size)} mode={modes.mode} confirm={confirm} hold={hold} holdingLabel={holdingLabel} holdProgress={modes.holdProgress} undoLeft={modes.undoLeft} undoMs={undoMs} count={count} progress={progress} disabledReason={disabledReason} reasonId={reasonId}>
        {children}
      </ButtonContent>
    </ButtonPrimitive>
  );
  // Plate keys and hold keys rest on a plate (a hold sinks the key onto it).
  return (variant === "plate" || hold) && !flat ? <ButtonPlate>{button}</ButtonPlate> : button;
}

export { ButtonClient };
export type { ButtonProps };
```

### lib/beautiful-ui/notchset/button-variants.ts

```tsx
import { cva } from "class-variance-authority";

/*
 * Notchset Button's variants, server-safe like shadcn's: call buttonVariants() from a Server Component
 * (links styled as buttons). The interactive Button lives in a client module.
 */

export const buttonVariants = cva(
  "group/button relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center gap-(--b-gap) rounded-none border-0 font-mono font-medium tracking-[0.06em] whitespace-nowrap uppercase outline-none select-none transition-[background-color,color,translate,border-color] duration-[var(--notchset-fade),var(--notchset-fade),var(--notchset-key-up),var(--notchset-fade)] ease-[var(--notchset-ease-fade),var(--notchset-ease-fade),var(--notchset-ease-key-up),var(--notchset-ease-fade)] disabled:cursor-not-allowed disabled:opacity-40 aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-busy:cursor-progress [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--b-ic) aria-pressed:bg-primary aria-pressed:text-primary-foreground data-[mode=armed]:bg-[var(--notchset-signal-text,var(--destructive))] data-[mode=armed]:text-primary-foreground data-[mode=hold]:translate-x-[2px] data-[mode=hold]:translate-y-[2px] data-[mode=hold]:duration-[var(--notchset-key-down)] data-[mode=hold]:ease-[var(--notchset-ease-key-down)]",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-[var(--notchset-primary-hover,color-mix(in_oklab,var(--primary)_86%,var(--background)))] active:bg-[var(--notchset-primary-press,var(--primary))] [--b-tick:var(--notchset-tick-on-primary,color-mix(in_oklab,var(--primary-foreground)_34%,transparent))] [--b-kbd:var(--notchset-kbd-on-primary,color-mix(in_oklab,var(--primary-foreground)_45%,transparent))]",
        destructive:
          "bg-[var(--notchset-signal,var(--destructive))] text-[color:var(--notchset-on-signal,white)] hover:bg-[var(--notchset-signal-hover,var(--destructive))] active:bg-[var(--notchset-signal-press,var(--destructive))] [--b-tick:var(--notchset-tick-on-primary,color-mix(in_oklab,white_34%,transparent))] [--b-kbd:var(--notchset-kbd-on-signal,color-mix(in_oklab,black_40%,transparent))] [--notchset-focus-color:var(--notchset-signal-text,var(--destructive))]",
        outline: "border border-solid border-foreground bg-transparent text-foreground hover:bg-accent active:bg-[var(--notchset-tint-press,var(--accent))]",
        secondary: "bg-secondary text-secondary-foreground hover:bg-[var(--notchset-secondary-hover,var(--secondary))] active:bg-[var(--notchset-secondary-press,var(--secondary))]",
        signal:
          "border border-solid border-[var(--notchset-signal-text,var(--destructive))] bg-background text-[color:var(--notchset-signal-text,var(--destructive))] hover:bg-[var(--notchset-tint,color-mix(in_oklab,var(--destructive)_12%,var(--background)))] [--notchset-button-fill:color-mix(in_oklab,var(--notchset-signal,var(--destructive))_26%,var(--background))] [--notchset-button-meter:var(--notchset-signal,var(--destructive))] [--notchset-focus-color:var(--notchset-signal-text,var(--destructive))]",
        ghost: "bg-transparent text-foreground hover:bg-accent active:bg-[var(--notchset-tint-press,var(--accent))]",
        link: "h-auto min-w-0 justify-start bg-transparent px-0.5 pt-[3px] pb-1.5 text-foreground active:text-[var(--notchset-signal-text,var(--destructive))]",
        plate: "bg-primary text-primary-foreground hover:bg-[var(--notchset-primary-hover,color-mix(in_oklab,var(--primary)_86%,var(--background)))] active:translate-x-[2px] active:translate-y-[2px] active:duration-[var(--notchset-key-down)] active:ease-[var(--notchset-ease-key-down)]",
      },
      size: {
        // The market's heights (shadcn, and Kobra on it): XS 24, S 28, default 32, L 36. Labels are mono caps,
        // which read larger than the 14px sans those libraries use, so 12px (13px on L) matches them optically.
        // Same size names as shadcn, so it stays a drop-in. At least 44px tall on touch screens.
        default: "h-8 min-w-8 px-3 text-[12px] [--b-gap:8px] [--b-ic:16px] [--b-kh:18px] [--b-kfs:10px] pointer-coarse:min-h-11",
        xs: "h-6 min-w-6 px-2 text-[12px] [--b-gap:6px] [--b-ic:14px] [--b-kh:14px] [--b-kfs:10px] pointer-coarse:min-h-11",
        sm: "h-7 min-w-7 px-2.5 text-[12px] [--b-gap:7px] [--b-ic:16px] [--b-kh:16px] [--b-kfs:10px] pointer-coarse:min-h-11",
        lg: "h-9 min-w-9 px-4 text-[13px] [--b-gap:8px] [--b-ic:16px] [--b-kh:20px] [--b-kfs:10px] pointer-coarse:min-h-11",
        icon: "size-8 [--b-ic:16px]",
        "icon-xs": "size-6 [--b-ic:14px]",
        "icon-sm": "size-7 [--b-ic:16px]",
        "icon-lg": "size-9 [--b-ic:16px]",
      },
    },
    // The link's drawn rule lives in the Notchset foundation (a system class, kept out of the utilities).
    // The shared press (a 1px content dip) on every variant but plate, which moves whole onto its plate.
    compoundVariants: [
      { variant: "link", class: `notchset-link` },
      { variant: ["default", "destructive", "outline", "secondary", "signal", "ghost", "link"], class: `notchset-press` },
    ],
    defaultVariants: { variant: "default", size: "default" },
  },
);

export type ButtonVariant = NonNullable<Parameters<typeof buttonVariants>[0]>["variant"];
export type ButtonSize = NonNullable<Parameters<typeof buttonVariants>[0]>["size"];
```

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

```tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonSize } from "@/lib/beautiful-ui/notchset/button-variants";
import { Check, RollingNumber, Scanner, playCue, useLatest } from "@/lib/beautiful-ui/notchset/instrument";

/*
 * Notchset Button, shared by both builds (the base Button). shadcn's Button in Notchset:
 * square, mono caps, flat at rest, a tick ruler along the bottom on hover, a five-tick scanner while
 * it works and a check that traces in when it's done. The Plate variant rests on a 1px plate offset
 * 2px and presses onto it. The width never moves: the idle, loading and done labels share one grid
 * cell, so the widest sets it (no measuring).
 */

export type ButtonPhase = "idle" | "loading" | "success";

/** Line glyphs on a 24 grid, 1.5 stroke, square caps. */
export const BUTTON_ICONS = {
  play: "M7 4 V20 L19 12 Z",
  arrow: "M4 12 H19 M13 6 L19 12 L13 18",
  plus: "M12 4 V20 M4 12 H20",
  trace: "M4 18 H10 V6 H20",
  stop: "M6 6 H18 V18 H6 Z",
  copy: "M8 8 H20 V20 H8 Z M4 16 V4 H16",
  external: "M14 4 H20 V10 M20 4 L11 13 M17 14 V20 H4 V7 H10",
} as const;
export type ButtonIconName = keyof typeof BUTTON_ICONS;

export type { ButtonSize, ButtonVariant } from "@/lib/beautiful-ui/notchset/button-variants";

/** What Notchset adds to shadcn's Button props. All optional. */
export type ButtonExtras = {
  /** A Notchset glyph, or your own icon (an svg). shadcn-style icon children work too. */
  icon?: ButtonIconName | React.ReactNode;
  iconPosition?: "left" | "right";
  /** A shortcut hint in a box (e.g. "⌘↵"); display only. */
  kbd?: string;
  /** Controlled working state: the scanner and loadingLabel; clicks are ignored. */
  loading?: boolean;
  /** Controlled done state: the check and successLabel. */
  success?: boolean;
  loadingLabel?: string;
  successLabel?: string;
  /** How long an automatic success shows before idle (ms). Default 1400. */
  successHold?: number;
  /** No hover ruler and, on Plate, no plate. */
  flat?: boolean;
  /** A toggle: stays filled with ink while true (aria-pressed). */
  pressed?: boolean;
  /** A number after the label that rolls when it changes (a like count). */
  count?: number;
  /** 0–1: a fill grows inside the key and a rolling percentage follows the label (an upload). */
  progress?: number;
  /** Disabled with a reason: still focusable, says why in a tag above it, and ignores presses. */
  disabledReason?: string;
  /** Two steps: the first press arms it (this label, signal fill), the second runs onClick. Disarms after 3s or on Esc. */
  confirm?: string;
  /** Press and hold for this long (ms) to run onHoldComplete: the key sinks onto its plate while a fill grows. */
  hold?: number;
  holdingLabel?: string;
  onHoldComplete?: () => void;
  /** After a hold completes, the key offers Undo for this long (ms; default 5000, 0 for none). */
  undoMs?: number;
  onUndo?: () => void;
};

export type ButtonMode = "idle" | "armed" | "hold" | "undo";

const ARM_MS = 3000;

/**
 * The modes beyond a plain press: armed (confirm), hold and undo. Returns the mode, the hold's progress
 * and the undo seconds left, handlers to merge onto the button, and `consume(e)`, which the click handler
 * calls first: true means the press was the mode's (arm, undo, a reason) and onClick must not run.
 */
export function useButtonModes({ confirm, hold, onHoldComplete, undoMs = 5000, onUndo, disabledReason, disabled }: Pick<ButtonExtras, "confirm" | "hold" | "onHoldComplete" | "undoMs" | "onUndo" | "disabledReason"> & { disabled?: boolean }) {
  const [mode, setMode] = React.useState<ButtonMode>("idle");
  const [holdProgress, setHoldProgress] = React.useState(0);
  const [undoLeft, setUndoLeft] = React.useState(0);
  const op = React.useRef(0);
  const latest = useLatest({ onHoldComplete, onUndo });
  const holdMs = hold && Number.isFinite(hold) && hold > 0 ? hold : 0;
  const undoWindow = Number.isFinite(undoMs) && undoMs > 0 ? undoMs : 0;

  // Armed disarms by itself.
  React.useEffect(() => {
    if (mode !== "armed") return;
    const t = setTimeout(() => setMode("idle"), ARM_MS);
    return () => clearTimeout(t);
  }, [mode]);

  // Undo counts down whole seconds toward an absolute deadline; at zero the action is final.
  React.useEffect(() => {
    if (mode !== "undo") return;
    const deadline = performance.now() + undoWindow;
    const iv = setInterval(() => {
      const left = Math.max(0, Math.ceil((deadline - performance.now()) / 1000));
      setUndoLeft(left);
      if (left <= 0) {
        clearInterval(iv);
        setMode("idle");
      }
    }, 200);
    return () => clearInterval(iv);
  }, [mode, undoWindow]);

  const cancelHold = () => {
    if (mode !== "hold") return;
    op.current++;
    setMode("idle");
    setHoldProgress(0);
  };
  const startHold = (el: HTMLElement) => {
    if (!holdMs || mode !== "idle" || disabled || disabledReason) return;
    const id = ++op.current;
    playCue(el, "signal");
    setMode("hold");
    const t0 = performance.now();
    // A timeout loop, not only requestAnimationFrame, so a hold still completes in a background tab.
    const step = () => {
      if (op.current !== id) return;
      const p = Math.min(1, (performance.now() - t0) / holdMs);
      setHoldProgress(p);
      if (p < 1) {
        setTimeout(step, 16);
        return;
      }
      playCue(el, "confirm");
      latest.current.onHoldComplete?.();
      setHoldProgress(0);
      if (undoWindow) {
        setUndoLeft(Math.ceil(undoWindow / 1000));
        setMode("undo");
      } else setMode("idle");
    };
    setTimeout(step, 16);
  };

  const consume = (e: React.MouseEvent<HTMLElement>) => {
    if (disabledReason) {
      e.preventDefault();
      playCue(e.currentTarget, "signal");
      return true;
    }
    if (mode === "undo") {
      playCue(e.currentTarget, "tick");
      setMode("idle");
      latest.current.onUndo?.();
      return true;
    }
    // A hold button runs on the hold, never on a click.
    if (holdMs) return true;
    if (confirm && mode !== "armed") {
      playCue(e.currentTarget, "signal");
      setMode("armed");
      return true;
    }
    if (mode === "armed") setMode("idle");
    return false;
  };

  const handlers = {
    onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
      if (!holdMs || e.button !== 0) return;
      e.currentTarget.setPointerCapture?.(e.pointerId);
      startHold(e.currentTarget);
    },
    onPointerUp: cancelHold,
    onPointerCancel: cancelHold,
    onLostPointerCapture: cancelHold,
    onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
      if (e.key === "Escape" && (mode === "armed" || mode === "hold")) {
        e.preventDefault();
        if (mode === "armed") setMode("idle");
        else cancelHold();
        return;
      }
      if (holdMs && (e.key === " " || e.key === "Enter") && !e.repeat && mode === "idle") {
        e.preventDefault();
        startHold(e.currentTarget);
      }
    },
    onKeyUp: (e: React.KeyboardEvent<HTMLElement>) => {
      if (holdMs && (e.key === " " || e.key === "Enter")) cancelHold();
    },
    onBlur: () => {
      cancelHold();
      setMode((m) => (m === "armed" ? "idle" : m));
    },
  };
  return { mode, holdProgress, undoLeft, consume, handlers };
}

/** Merges two handlers: yours first, then the button's unless you prevented the default. */
export function chain<E extends React.SyntheticEvent>(yours: ((e: E) => void) | undefined, ours: (e: E) => void) {
  return (e: E) => {
    yours?.(e);
    if (!e.defaultPrevented) ours(e);
  };
}

/** Runs an async click: loading while the Promise is pending, then success for successHold, then idle. */
export function useButtonPhase({ loading, success, successHold = 1400 }: Pick<ButtonExtras, "loading" | "success" | "successHold">) {
  const [own, setOwn] = React.useState<ButtonPhase>("idle");
  React.useEffect(() => {
    if (own !== "success") return;
    const t = setTimeout(() => setOwn("idle"), successHold);
    return () => clearTimeout(t);
  }, [own, successHold]);
  const phase: ButtonPhase = loading ? "loading" : success ? "success" : own;
  const run = (el: HTMLElement | null, result: unknown) => {
    if (!result || typeof (result as Promise<unknown>).then !== "function" || loading !== undefined) return;
    setOwn("loading");
    (result as Promise<unknown>).then(
      () => {
        playCue(el, "done");
        setOwn("success");
      },
      () => setOwn("idle"),
    );
  };
  return [phase, run] as const;
}

function Glyph({ icon }: { icon: ButtonExtras["icon"] }) {
  if (typeof icon === "string" && icon in BUTTON_ICONS) {
    return (
      <svg aria-hidden viewBox="0 0 24 24">
        <path d={BUTTON_ICONS[icon as ButtonIconName]} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="square" strokeLinejoin="miter" />
      </svg>
    );
  }
  return <>{icon as React.ReactNode}</>;
}

/**
 * The inside: the icon slot (icon, scanner or check), the labels stacked in one grid cell (the
 * widest sets the width; only the current one is visible and read), the kbd box, and the ruler.
 */
function ButtonContent({
  phase,
  icon,
  iconPosition = "left",
  kbd,
  loadingLabel,
  successLabel,
  children,
  ruler,
  iconOnly,
  mode = "idle",
  confirm,
  holdingLabel = "KEEP HOLDING",
  hold,
  holdProgress = 0,
  undoLeft = 0,
  undoMs,
  count,
  progress,
  disabledReason,
  reasonId,
}: ButtonExtras & { phase: ButtonPhase; children?: React.ReactNode; ruler: boolean; iconOnly: boolean; mode?: ButtonMode; holdProgress?: number; undoLeft?: number; reasonId?: string }) {
  const undoSeconds = Math.ceil((undoMs ?? 5000) / 1000);
  const hasStates = loadingLabel !== undefined || successLabel !== undefined || confirm !== undefined || Boolean(hold);
  const working = typeof progress === "number" && progress >= 0 && progress < 1;
  const iconSlot =
    phase === "loading" ? (
      <Scanner height={12} />
    ) : phase === "success" ? (
      <Check />
    ) : icon !== undefined ? (
      <Glyph icon={icon} />
    ) : null;
  const label = (text: React.ReactNode, on: boolean) => (
    <span data-slot="button-label" aria-hidden={!on || undefined} className={cn("col-start-1 row-start-1 transition-opacity duration-(--notchset-fade) ease-(--notchset-ease-fade)", on ? "opacity-100" : "invisible opacity-0")}>
      {text}
    </span>
  );
  const shown = mode === "armed" ? "armed" : mode === "hold" ? "hold" : mode === "undo" ? "undo" : phase;
  return (
    <>
      {iconPosition === "left" && iconSlot && (
        <span data-slot="button-icon" className="flex size-(--b-ic) items-center justify-center">
          {iconSlot}
        </span>
      )}
      {!iconOnly &&
        (hasStates ? (
          <span className="grid">
            {label(children, shown === "idle")}
            {label(loadingLabel ?? children, shown === "loading")}
            {label(successLabel ?? children, shown === "success")}
            {confirm !== undefined && label(confirm, shown === "armed")}
            {hold ? label(holdingLabel, shown === "hold") : null}
            {/* The undo label is laid out at its widest (the full window) so the width never moves. */}
            {hold && (undoMs ?? 5000) > 0 ? label(`UNDO · ${shown === "undo" ? undoLeft : undoSeconds}S`, shown === "undo") : null}
          </span>
        ) : (
          children
        ))}
      {iconPosition === "right" && iconSlot && (
        <span data-slot="button-icon" className="flex size-(--b-ic) items-center justify-center">
          {iconSlot}
        </span>
      )}
      {iconOnly && phase === "idle" && !iconSlot && children}
      {count !== undefined && <RollingNumber value={count} height={14} className="font-medium" />}
      {working && <RollingNumber value={`${Math.round(progress * 100)}%`} height={14} className="font-normal opacity-80" />}
      {kbd && !iconOnly && phase !== "success" && (
        <span
          aria-hidden
          data-slot="button-kbd"
          className="flex h-(--b-kh) items-center border border-solid border-(--b-kbd,color-mix(in_oklab,currentColor_32%,transparent)) px-[5px] text-(length:--b-kfs) font-normal tracking-[0.02em] tabular-nums pointer-coarse:hidden"
        >
          {kbd}
        </span>
      )}
      {/* The hold or progress fill behind the content, and its meter along the bottom edge: it reads as a
          loader on every key (red on signal keys). */}
      {(mode === "hold" || working) && (
        <>
          <span aria-hidden data-slot="button-fill" className="pointer-events-none absolute inset-y-0 start-0 -z-10 bg-(--notchset-button-fill,color-mix(in_oklab,currentColor_26%,transparent))" style={{ width: `${((mode === "hold" ? holdProgress : (progress ?? 0)) * 100).toFixed(2)}%` }} />
          <span aria-hidden data-slot="button-meter" className="pointer-events-none absolute start-0 bottom-0 h-[3px] bg-(--notchset-button-meter,currentColor)" style={{ width: `${((mode === "hold" ? holdProgress : (progress ?? 0)) * 100).toFixed(2)}%` }} />
        </>
      )}
      {disabledReason && (
        <span
          id={reasonId}
          role="tooltip"
          data-slot="button-reason"
          className="pointer-events-none absolute bottom-[calc(100%+10px)] left-1/2 z-20 flex h-7 -translate-x-1/2 translate-y-1 items-center bg-primary px-2.5 font-mono text-[11px] font-medium tracking-[0.06em] whitespace-nowrap text-primary-foreground uppercase opacity-0 transition-[opacity,translate] duration-[160ms] group-hover/button:translate-y-0 group-hover/button:opacity-100 group-focus-visible/button:translate-y-0 group-focus-visible/button:opacity-100"
        >
          {disabledReason}
        </span>
      )}
      {ruler && (
        <span
          aria-hidden
          data-slot="button-ruler"
          className="pointer-events-none absolute inset-x-0 bottom-0 h-1 opacity-0 transition-opacity duration-(--notchset-fade) ease-(--notchset-ease-fade) [background-image:repeating-linear-gradient(90deg,var(--b-tick,color-mix(in_oklab,currentColor_30%,transparent))_0_1px,transparent_1px_4px)] group-hover/button:opacity-100 group-aria-busy/button:!opacity-0 pointer-coarse:hidden"
        />
      )}
    </>
  );
}

/** The plate under a Plate button: a 1px frame offset 2px that the key presses onto. */
function ButtonPlate({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <span
      data-slot="button-plated"
      className={cn(
        "relative inline-flex [--notchset-plate-color:var(--notchset-control-edge,var(--input))] has-[:hover,:focus-visible,[aria-busy=true]]:[--notchset-plate-color:var(--foreground)] has-[:disabled,[aria-disabled=true]]:[--notchset-plate-color:var(--notchset-rule,var(--border))] has-[[data-variant=signal][data-mode=hold],[data-variant=signal][data-mode=undo]]:[--notchset-plate-color:var(--notchset-signal-text,var(--destructive))]",
        className,
      )}
    >
      <span aria-hidden data-slot="plate" className={`notchset-plate`} />
      {children}
    </span>
  );
}

const iconSize = (size: ButtonSize) => typeof size === "string" && size.startsWith("icon");

export { ButtonContent, ButtonPlate, buttonVariants, iconSize };
```

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

```tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
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
// ---------------------------------------------------------------------------------------------

/**
 * 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);
      },
    );
  });
}

/** True under prefers-reduced-motion or inside a data-motion="off" scope. Checked when an effect runs. */
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;
  };
}

/**
 * Puts text on the clipboard. Resolves true once it is there and false when the browser refuses (no
 * permission, an insecure page, no clipboard API), so a "copied" state is only ever shown for a copy.
 */
export async function copyText(text: string): Promise<boolean> {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    return false;
  }
}

/** The glide easing shared by travelling marks (selection blocks, highlights, runs). */
export const GLIDE = "cubic-bezier(.65,0,.35,1)";

/** Base UI's className may be a function of state: merges ours with it in either form. */
export const withClass =
  <S,>(base: string, className: string | ((state: S) => string | undefined) | undefined) =>
  (state: S) =>
    cn(base, typeof className === "function" ? className(state) : className);

/** 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 each step on its own animation frame, in order (the first on the next frame), and returns one
 * cancel that stops whichever frame is pending. A no-op step just waits a frame: frames(set pre, wait,
 * go) lets the browser paint the start state before the transition begins.
 */
export function frames(...steps: (() => void)[]): () => void {
  let id = 0;
  let i = 0;
  const tick = () => {
    steps[i++]?.();
    if (i < steps.length) id = requestAnimationFrame(tick);
  };
  id = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(id);
}
export const wait = () => {};

let idSeq = 0;
/** A unique id for something the user creates (a milestone, a note): random where the browser can, else a counter. */
export const newId = () => (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `id-${Date.now()}-${++idSeq}`);

/**
 * True while an input method (Japanese, Chinese, Korean…) is composing: Enter, Tab and delimiters
 * then belong to the composition, not to the component. keyCode 229 covers Safari's late flag.
 */
export function composing(e: React.KeyboardEvent | KeyboardEvent) {
  const native = "nativeEvent" in e ? e.nativeEvent : e;
  return native.isComposing || native.keyCode === 229;
}

const hotkeyOwners = new Map<string, number[]>();
let hotkeySeq = 0;
/**
 * A page-wide shortcut ("/" or "mod+k", mod being ⌘ or Ctrl) with a single owner: when several
 * components claim the same key, only the one mounted last answers, so one press never opens two.
 * A plain key is ignored while typing in a field.
 */
export function useHotkey(combo: string | undefined, run: () => void) {
  const latest = useLatest(run);
  React.useEffect(() => {
    if (!combo) return;
    const me = ++hotkeySeq;
    const stack = hotkeyOwners.get(combo) ?? [];
    stack.push(me);
    hotkeyOwners.set(combo, stack);
    const mod = combo.startsWith("mod+");
    const key = (mod ? combo.slice(4) : combo).toLowerCase();
    const onKey = (e: KeyboardEvent) => {
      if (hotkeyOwners.get(combo)?.at(-1) !== me || e.key.toLowerCase() !== key) return;
      if (mod ? !(e.metaKey || e.ctrlKey) : e.metaKey || e.ctrlKey || e.altKey) return;
      if (!mod && (e.target as HTMLElement | null)?.closest("input, textarea, select, [contenteditable=true]")) return;
      e.preventDefault();
      latest.current();
    };
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("keydown", onKey);
      const s = hotkeyOwners.get(combo) ?? [];
      s.splice(s.indexOf(me), 1);
    };
  }, [combo, latest]);
}

/** 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;
}

const subscribeNothing = () => () => {};

/**
 * A scope that turns motion or sound off for everything inside it, popups included. A plain
 * data-motion="off" wrapper can't reach a menu that portals to the body; this one also gives its
 * overlays a portal host carrying the same preferences (inside any outer scope's host), so motionOff
 * and the cues see them there too.
 */
export function NotchsetScope({ motion, sound, children, ...props }: React.ComponentProps<"div"> & { motion?: "off"; sound?: "off" }) {
  const outer = React.useContext(NotchsetPortalContext);
  const [host, setHost] = React.useState<HTMLElement | null>(null);
  const client = React.useSyncExternalStore(subscribeNothing, () => true, () => false);
  return (
    <div data-motion={motion} data-sound={sound} {...props}>
      <NotchsetPortalContext value={host ?? outer}>{children}</NotchsetPortalContext>
      {client && createPortal(<div ref={setHost} data-slot="notchset-scope-host" data-motion={motion} data-sound={sound} />, outer ?? document.body)}
    </div>
  );
}

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([" ", "·", "/", ".", ":"]);
const FLIP_CHARS = 24;

export function scramble(text: string, elapsed: number) {
  let out = "";
  for (let i = 0; i < text.length; i++) {
    const ch = text[i]!;
    // Characters past the 24th land with the 24th, so a long label flips as briefly as a short one.
    out += STILL.has(ch) || elapsed >= 110 + Math.min(i, FLIP_CHARS) * 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 + Math.min(text.length, FLIP_CHARS) * 14 + 40;
    const step = (now: number) => {
      const elapsed = now - t0;
      if (elapsed >= end) {
        setFrame(null);
        return;
      }
      setFrame({ for: text, shown: scramble(text, elapsed) });
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [text, off, el]);
  // Turned off mid-flip, the label shows its text at once (the cancelled frame never lingers).
  return !off && 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, ref: refProp, ...props }: Omit<React.ComponentProps<"span">, "children"> & { children: string; off?: boolean }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const refs = useComposedRefs<HTMLSpanElement>(refProp, ref);
  const shown = useGlyphFlip(children, { off, el: ref });
  return (
    <span {...props} ref={refs} data-slot="flip-text" className={cn("relative whitespace-nowrap", className)}>
      <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;
}

/**
 * Digits that roll: each digit is a 0–9 strip; the ones digit moves first and each digit to its left
 * 40ms later, counting digits only (620ms, a small overshoot). `delay` offsets the whole text. Other
 * characters (",", "$", "%", letters) sit still. Screen readers get the plain text. The one
 * implementation behind RollingNumber and the calendar's RollingLabel.
 */
export function DigitRoll({ text, height, delay = 0, className }: { text: string; height: number; delay?: number; className?: string }) {
  const chars = [...text];
  let k = 0;
  const order = chars.map((_, i) => (/\d/.test(chars[chars.length - 1 - i]!) ? k++ : 0)).reverse();
  return (
    <span className={cn("relative inline-flex flex-none overflow-hidden tabular-nums", className)} style={{ height, lineHeight: `${height}px` }}>
      <span className="sr-only">{text}</span>
      <span aria-hidden className="flex">
        {chars.map((d, i) =>
          /\d/.test(d) ? (
            <span
              key={chars.length - i}
              className="flex flex-col transition-transform duration-[620ms] ease-[cubic-bezier(.34,1.15,.5,1)] motion-reduce:transition-none"
              style={{ transform: `translateY(${-Number(d) * height}px)`, transitionDelay: `${delay + order[i]! * 40}ms` }}
            >
              {Array.from({ length: 10 }, (_, n) => (
                <span key={n} style={{ height }}>
                  {n}
                </span>
              ))}
            </span>
          ) : (
            <span key={`s${chars.length - i}`}>{d}</span>
          ),
        )}
      </span>
    </span>
  );
}

/** A number that rolls (DigitRoll at the readout's own size). `delay` offsets it (rows 60ms apart). */
function RollingNumber({ value, height = 32, delay = 0, className }: { value: string | number; height?: number; delay?: number; className?: string }) {
  return <DigitRoll text={String(value)} height={height} delay={delay} {...(className ? { className } : {})} />;
}

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://notchset.dev/r/notchset-foundation.json.
