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

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

shadcn's Sheet in Notchset: a panel that slides in from an edge over a scrim, with a 1px ink rule on its inner side. Side sheets can be resizable; a bottom sheet has a drag handle with snap points and dismisses when dragged down; non-modal mode keeps the page usable so a list can swap what the sheet shows.

## 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` (Base UI build); `radix-ui@^1` (Radix build).
- Files: `components/ui/notchset/sheet.tsx`; shared code: `lib/beautiful-ui/notchset/sheet.tsx`, `lib/beautiful-ui/notchset/dialog.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: `Sheet`, `SheetBody`, `SheetClose`, `SheetContent`, `SheetDescription`, `SheetFooter`, `SheetHeader`, `SheetTitle`, `SheetTrigger`, 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 { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/notchset/sheet";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Sheet` | `sheet` | Root; open state and modal. |
| `SheetTrigger / SheetClose` | `sheet-trigger` | Open and close controls. |
| `SheetContent` | `sheet-content` | The panel, scrim, handles and close button. |
| `SheetHeader / SheetBody / SheetFooter` | `sheet-header` | 64px title row, scrolling body, actions row. |
| `SheetTitle / SheetDescription` | `sheet-title` | Labels. |

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/sheet.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
- sheet, drawer, side panel, bottom sheet, detail panel, navigation drawer, Notchset
- Detail for a row without leaving the list
- A phone's bottom sheet or menu drawer

### Not when
- A decision that can't be undone: use Alert Dialog
- A small panel on a control: use Popover

## Mistakes
- Give every sheet a SheetTitle
- Non-modal sheets need their own way to close (the close button is kept)

## Usage

```tsx
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/notchset/sheet";

export function RunDetail() {
  return (
    <Sheet>
      <SheetTrigger>Open run</SheetTrigger>
      <SheetContent resizable={{ defaultSize: 480 }}>
        <SheetHeader><SheetTitle>RUN_8F3A1</SheetTitle></SheetHeader>
        <SheetBody>…</SheetBody>
      </SheetContent>
    </Sheet>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `side (SheetContent)` | `'right' \| 'left' \| 'bottom' \| 'top'` |  | The edge it comes from. Default right. |
| `modal (Sheet)` | `boolean` |  | Scrim, focus trap and scroll lock (default). false keeps the page usable and ignores outside clicks. |
| `resizable (SheetContent)` | `{ defaultSize?, min?, max?, label? }` |  | Left or right: a separator on the inner edge, by pointer or ←/→ (360–720px by default). |
| `snapPoints (SheetContent)` | `number[]` |  | Bottom: heights as fractions, with a drag handle. Default [0.62, 0.92]; drag down 120px to dismiss. |
| `contained (SheetContent)` | `boolean` |  | Place it inside its portal container (position: relative) instead of the viewport. |

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

## 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 |
|---|---|
| Esc | Close |
| ← / → | Resize (on the separator) |
| Enter / Space | Step between snap points (on the handle) |

## Performance

- Mounted only while open; drag and resize write one style per pointer move.

## Responsive

- Side sheets leave 40px of the page showing; a bottom sheet never covers the last 24px.

## Motion inventory

| Interaction | What moves |
|---|---|
| Open | Slides in on the unroll (420ms); the scrim fades in (360ms) |
| Close | Slides out on the trace (260ms) |
| Snap | Height eases between snap points (380ms) |

## Accessibility contract (preserve when editing)
- A dialog labelled by its title; focus moves in on open and back to the opener on close
- Modal sheets trap focus and lock the page's scroll
- The resize separator has aria-valuenow, min and max; the drag handle is a button that steps between snap points

## Install

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

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

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/sheet.tsx

```tsx
"use client";

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

import * as React from "react";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { NOTCHSET_ROOT, OWN_SOUND, useComposedRefs, useNotchsetPortal, withClass } from "@/lib/beautiful-ui/notchset/instrument";
import { CloseGlyph } from "@/lib/beautiful-ui/notchset/dialog";
import {
  SheetBody,
  SheetContext,
  SheetDragHandle,
  SheetFooter,
  SheetHeader,
  SheetResizeHandle,
  sheetCloseClass,
  sheetContentClass,
  sheetDescriptionClass,
  sheetOverlayClass,
  sheetTitleClass,
  useSheetOpen,
  type SheetSide,
} from "@/lib/beautiful-ui/notchset/sheet";

/*
 * Notchset Sheet (Base UI build, Dialog). shadcn's Sheet, same parts, in Notchset: it slides in from an
 * edge over a scrim, traps focus and locks the page's scroll while modal, closes on Esc and returns
 * focus to what opened it. modal={false} leaves the page usable behind it (no scrim, no outside-click
 * close), so a list can swap what the sheet shows. A side sheet can be resizable; a bottom sheet
 * has a drag handle with snap points and dismisses when dragged down.
 */

function Sheet({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  modal = true,
  ...props
}: Omit<SheetPrimitive.Root.Props, "onOpenChange" | "modal"> & { onOpenChange?: (open: boolean) => void; modal?: boolean }) {
  const [open, setOpen] = useSheetOpen(openProp, defaultOpen, onOpenChange);
  const state = React.useMemo(() => ({ open, setOpen, modal }), [open, setOpen, modal]);
  return (
    <SheetContext value={state}>
      <SheetPrimitive.Root data-slot="sheet" open={open} onOpenChange={(o) => setOpen(o)} modal={modal} disablePointerDismissal={!modal} {...props} />
    </SheetContext>
  );
}

function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
  return <SheetPrimitive.Trigger {...OWN_SOUND} data-slot="sheet-trigger" {...props} />;
}

function SheetClose(props: SheetPrimitive.Close.Props) {
  return <SheetPrimitive.Close {...OWN_SOUND} data-slot="sheet-close" {...props} />;
}

type ContentProps = SheetPrimitive.Popup.Props & {
  side?: SheetSide;
  /** Place it in its portal container (position: relative) instead of the viewport. */
  contained?: boolean;
  container?: SheetPrimitive.Portal.Props["container"];
  showCloseButton?: boolean;
  closeLabel?: string;
  /** Left or right: a separator on the inner edge resizes it between min and max (px). */
  resizable?: { defaultSize?: number; min?: number; max?: number; label?: string };
  /** Bottom: heights as fractions of the container, with a drag handle (default [0.62, 0.92]). */
  snapPoints?: readonly number[];
};

function SheetContent({ side = "right", contained = false, container, showCloseButton = true, closeLabel = "Close", resizable, snapPoints, className, style, children, ref, ...props }: ContentProps) {
  const sheet = React.useContext(SheetContext);
  const popup = React.useRef<HTMLDivElement>(null);
  const refs = useComposedRefs<HTMLDivElement>(ref, popup);
  const scoped = useNotchsetPortal();
  const [size, setSize] = React.useState(resizable?.defaultSize ?? 480);
  const points = side === "bottom" ? (snapPoints ?? [0.62, 0.92]) : null;
  const [snap, setSnap] = React.useState(0);
  const [drag, setDrag] = React.useState<{ dy: number; on: boolean }>({ dy: 0, on: false });
  // A fresh opening starts at the first snap point.
  const [wasOpen, setWasOpen] = React.useState(sheet?.open);
  if (sheet?.open !== wasOpen) {
    setWasOpen(sheet?.open);
    if (sheet?.open) setSnap(0);
  }
  const sizeVar = points ? `${(points[snap] ?? 0.62) * 100}%` : resizable && (side === "left" || side === "right") ? `${size}px` : undefined;
  return (
    <SheetPrimitive.Portal data-slot="sheet-portal" container={container ?? scoped}>
      {sheet?.modal !== false && <SheetPrimitive.Backdrop {...NOTCHSET_ROOT} data-slot="sheet-overlay" className={sheetOverlayClass(contained)} />}
      <SheetPrimitive.Popup
        {...NOTCHSET_ROOT}
        ref={refs}
        data-slot="sheet-content"
        // Focus goes to [data-autofocus] if there is one, else the close button (not the resize separator).
        initialFocus={() => popup.current?.querySelector<HTMLElement>("[data-autofocus]") ?? popup.current?.querySelector<HTMLElement>('[data-slot="sheet-close"]') ?? true}
        data-side={side}
        className={withClass(sheetContentClass(side, contained), className)}
        style={{
          ...(sizeVar ? { "--sheet-size": sizeVar } : {}),
          ...(drag.on || drag.dy ? { translate: `0 ${drag.dy}px`, transition: "none" } : {}),
          ...(style as React.CSSProperties),
        } as React.CSSProperties}
        {...props}
      >
        {points && (
          <SheetDragHandle
            snap={snap}
            snapPoints={points}
            onSnap={setSnap}
            onDrag={(dy, on) => setDrag({ dy, on })}
            onDismiss={() => {
              setDrag({ dy: 0, on: false });
              sheet?.setOpen(false);
            }}
          />
        )}
        {resizable && (side === "left" || side === "right") && (
          <SheetResizeHandle side={side} size={size} min={resizable.min ?? 360} max={resizable.max ?? 720} onResize={setSize} label={resizable.label} />
        )}
        {children}
        {showCloseButton && (
          <SheetPrimitive.Close {...OWN_SOUND} data-slot="sheet-close" aria-label={closeLabel} className={withClass(sheetCloseClass, side === "bottom" ? "absolute end-3 top-10" : "absolute end-3 top-3")}>
            <CloseGlyph />
          </SheetPrimitive.Close>
        )}
      </SheetPrimitive.Popup>
    </SheetPrimitive.Portal>
  );
}

function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
  return <SheetPrimitive.Title data-slot="sheet-title" className={withClass(sheetTitleClass, className)} {...props} />;
}

function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
  return <SheetPrimitive.Description data-slot="sheet-description" className={withClass(sheetDescriptionClass, className)} {...props} />;
}

export { Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
```

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

```tsx
"use client";

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

/*
 * Notchset Sheet, shared by both builds. A panel that slides in from an edge with a 1px ink rule on its
 * inner side: the right or left (run detail, a navigation drawer), the bottom (a phone sheet) or the top.
 * It slides in on the unroll (420ms) and back on the trace (260ms) over a scrim that fades. A side sheet
 * can be resizable (a separator on its inner edge, by pointer or ←/→); a bottom sheet has a drag
 * handle with snap points, and dragging it down past 120px dismisses it.
 */

export type SheetSide = "right" | "left" | "bottom" | "top";

/** The Sheet root shares its state, so the handles inside can resize, snap or close it. */
export type SheetState = { open: boolean; setOpen: (open: boolean) => void; modal: boolean };
export const SheetContext = React.createContext<SheetState | null>(null);

/** Controlled or uncontrolled open state, reported through onOpenChange. */
export function useSheetOpen(openProp: boolean | undefined, defaultOpen: boolean, onOpenChange?: (open: boolean) => void) {
  const [own, setOwn] = React.useState(defaultOpen);
  const open = openProp ?? own;
  const setOpen = React.useCallback(
    (next: boolean) => {
      setOwn(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );
  return [open, setOpen] as const;
}

const FROM: Record<SheetSide, string> = {
  right: "[--notchset-sheet-from:translateX(calc(100%_+_2px))] rtl:[--notchset-sheet-from:translateX(calc(-100%_-_2px))]",
  left: "[--notchset-sheet-from:translateX(calc(-100%_-_2px))] rtl:[--notchset-sheet-from:translateX(calc(100%_+_2px))]",
  bottom: "[--notchset-sheet-from:translateY(calc(100%_+_2px))]",
  top: "[--notchset-sheet-from:translateY(calc(-100%_-_2px))]",
};
const PLACE: Record<SheetSide, string> = {
  right: "inset-y-0 end-0 w-[var(--sheet-size,480px)] max-w-[calc(100%-40px)] border-0 border-s border-solid border-foreground",
  left: "inset-y-0 start-0 w-[var(--sheet-size,320px)] max-w-[calc(100%-40px)] border-0 border-e border-solid border-foreground",
  bottom: "inset-x-0 bottom-0 h-[var(--sheet-size,62%)] max-h-[calc(100%-24px)] border-0 border-t border-solid border-foreground",
  top: "inset-x-0 top-0 h-[var(--sheet-size,auto)] max-h-[calc(100%-24px)] border-0 border-b border-solid border-foreground",
};

/** The sheet's box. `contained` places it in its portal container (position: relative) instead of the viewport. */
export function sheetContentClass(side: SheetSide, contained: boolean) {
  return cn(
    contained ? "absolute" : "fixed",
    "z-50 box-border flex flex-col bg-background text-start text-foreground outline-none",
    PLACE[side],
    FROM[side],
    // Base UI: a transition between the starting and ending styles. Radix: the same as keyframes.
    "[transform:none] transition-[transform,height,width] duration-[420ms,380ms,0ms] ease-(--notchset-ease-unfold)",
    "data-[starting-style]:[transform:var(--notchset-sheet-from)] data-[ending-style]:[transform:var(--notchset-sheet-from)] data-[ending-style]:duration-[260ms] data-[ending-style]:ease-(--notchset-ease-travel)",
    "motion-safe:data-[state=open]:animate-[notchset-sheet-in_420ms_var(--notchset-ease-unfold)] motion-safe:data-[state=closed]:animate-[notchset-sheet-out_260ms_var(--notchset-ease-travel)_forwards]",
    "motion-reduce:transition-none",
  );
}

export function sheetOverlayClass(contained: boolean) {
  return cn(
    contained ? "absolute" : "fixed",
    "inset-0 z-50 bg-[var(--notchset-scrim,rgba(17,17,16,.42))] transition-opacity duration-[360ms] ease-linear data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-[260ms] motion-safe:data-[state=open]:animate-[notchset-fade-in_360ms_linear] motion-safe:data-[state=closed]:animate-[notchset-fade-out_260ms_linear_forwards] motion-reduce:transition-none",
  );
}

export const sheetCloseClass = cn(
  `notchset-focus`,
  "flex size-10 flex-none cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-foreground transition-colors duration-(--notchset-fade) ease-(--notchset-ease-fade) hover:bg-accent [--notchset-focus-inset:-4px] pointer-coarse:size-11",
);
export const sheetTitleClass = "m-0 min-w-0 truncate font-mono text-[16px] font-medium";
export const sheetDescriptionClass = "m-0 text-[14px] leading-[1.5] text-muted-foreground";

/** A 64px header: the title, anything beside it, and the close at the end. */
export function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-header"
      className={cn("flex min-h-16 flex-none items-center gap-3 border-0 border-b border-solid border-[var(--notchset-rule,var(--border))] ps-5 pe-16", className)}
      {...props}
    />
  );
}

/** The body: the part that scrolls. */
export function SheetBody({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="sheet-body" className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-5", className)} {...props} />;
}

/** The actions row along the bottom. */
export function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="sheet-footer" className={cn("flex flex-none flex-wrap gap-2 border-0 border-t border-solid border-[var(--notchset-rule,var(--border))] px-5 py-3", className)} {...props} />;
}

/**
 * A side sheet's resize separator on its inner edge: drag it, or focus it and press ← / →. The width
 * stays between min and max and leaves 120px of the page showing.
 */
export function SheetResizeHandle({
  side,
  size,
  min,
  max,
  onResize,
  label = "Resize panel",
}: {
  side: "left" | "right";
  size: number;
  min: number;
  max: number;
  onResize: (size: number) => void;
  label?: string;
}) {
  const [dragging, setDragging] = React.useState(false);
  const clamp = (v: number, room: number) => Math.round(Math.max(min, Math.min(max, room - 120, v)));
  const room = (el: HTMLElement) => (el.closest<HTMLElement>("[data-slot=sheet-content]")?.offsetParent as HTMLElement | null)?.clientWidth ?? window.innerWidth;
  return (
    <div
      {...OWN_SOUND}
      role="separator"
      aria-orientation="vertical"
      aria-label={label}
      aria-valuenow={size}
      aria-valuemin={min}
      aria-valuemax={max}
      tabIndex={0}
      data-slot="sheet-resize"
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId);
        setDragging(true);
      }}
      onPointerMove={(e) => {
        if (!dragging) return;
        const box = e.currentTarget.parentElement!.getBoundingClientRect();
        const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
        const fromEnd = (side === "right") !== rtl;
        onResize(clamp(fromEnd ? box.right - e.clientX : e.clientX - box.left, room(e.currentTarget)));
      }}
      onPointerUp={() => setDragging(false)}
      onPointerCancel={() => setDragging(false)}
      onKeyDown={(e) => {
        const grow = side === "right" ? "ArrowLeft" : "ArrowRight";
        const shrink = side === "right" ? "ArrowRight" : "ArrowLeft";
        const d = e.key === grow ? 20 : e.key === shrink ? -20 : 0;
        if (!d) return;
        e.preventDefault();
        onResize(clamp(size + d, room(e.currentTarget)));
      }}
      className={cn(
        `notchset-focus`, "group/resize absolute inset-y-0 z-[2] flex w-2.5 cursor-col-resize touch-none justify-center outline-none [--notchset-focus-inset:-2px]",
        side === "right" ? "-start-[5px]" : "-end-[5px]",
      )}
    >
      <span aria-hidden className={cn("h-full w-0.5 transition-colors duration-[120ms] ease-linear group-hover/resize:bg-foreground", dragging ? "bg-foreground" : "bg-transparent")} />
    </div>
  );
}

/**
 * A bottom sheet's drag handle. Drag up or down to move between the snap points (fractions of the
 * container's height); drag down past 120px to dismiss; press it (or Enter / Space) to step between them.
 */
export function SheetDragHandle({
  snap,
  snapPoints,
  onSnap,
  onDrag,
  onDismiss,
}: {
  snap: number;
  snapPoints: readonly number[];
  onSnap: (index: number) => void;
  /** The live offset in px while dragging (0 when released). */
  onDrag: (dy: number, dragging: boolean) => void;
  onDismiss: () => void;
}) {
  const start = React.useRef<{ y: number; moved: boolean } | null>(null);
  const [dragging, setDragging] = React.useState(false);
  const last = snapPoints.length - 1;
  const step = () => {
    playCue(document.activeElement, "tick");
    onSnap(snap >= last ? 0 : snap + 1);
  };
  return (
    <button
      {...OWN_SOUND}
      type="button"
      data-slot="sheet-handle"
      aria-label={snap >= last ? "Collapse sheet" : "Expand sheet"}
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId);
        start.current = { y: e.clientY, moved: false };
        setDragging(true);
      }}
      onPointerMove={(e) => {
        if (!start.current) return;
        const dy = e.clientY - start.current.y;
        if (Math.abs(dy) > 4) start.current.moved = true;
        // Upward drags resist (half speed) and stop 60px up.
        onDrag(Math.max(-60, dy < 0 ? dy / 2 : dy), true);
      }}
      onPointerUp={(e) => {
        const s = start.current;
        start.current = null;
        setDragging(false);
        if (!s) return;
        const dy = e.clientY - s.y;
        onDrag(0, false);
        if (!s.moved) return step();
        if (dy > 120) return onDismiss();
        if (dy < -30) onSnap(Math.min(last, snap + 1));
        else if (dy > 50) onSnap(Math.max(0, snap - 1));
      }}
      onPointerCancel={() => {
        start.current = null;
        setDragging(false);
        onDrag(0, false);
      }}
      onClick={(e) => {
        // Keyboard presses (no pointer down first) step between the snap points.
        if (e.detail === 0) step();
      }}
      className={cn(`notchset-focus`, "flex h-7 w-full flex-none cursor-grab touch-none items-center justify-center border-0 bg-transparent p-0 [--notchset-focus-inset:-3px] active:cursor-grabbing")}
    >
      <span aria-hidden className={cn("h-1 w-10 transition-colors duration-[120ms] ease-linear", dragging ? "bg-foreground" : "bg-[var(--notchset-control-edge,var(--input))]")} />
    </button>
  );
}
```

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

```tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { Node } from "@/lib/beautiful-ui/notchset/instrument";

/*
 * Notchset Dialog, shared by both builds: the classes for the scrim and the panel
 * (it unrolls from the top with clip-path and settles 8px; it rolls back up on close), the header
 * kicker, the comparison table and the context that lets DialogConfirm close the dialog when its
 * action has finished.
 */

export const dialogOverlayClass =
  "fixed inset-0 z-50 bg-[var(--notchset-scrim,rgba(17,17,16,.42))] transition-opacity duration-(--notchset-fade) ease-(--notchset-ease-fade) data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-200 motion-safe:data-[state=open]:animate-[notchset-fade-in_260ms_linear] motion-safe:data-[state=closed]:animate-[notchset-fade-out_200ms_linear]";

export const dialogContentClass = cn(
  "fixed top-1/2 left-1/2 z-50 flex w-[392px] max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col border border-solid border-foreground bg-background text-foreground outline-none",
  "[clip-path:inset(0)] transition-[clip-path,translate] duration-[420ms,520ms] ease-[var(--notchset-ease-unfold),cubic-bezier(.34,1.4,.5,1)] delay-[60ms,0ms]",
  "data-[starting-style]:[clip-path:inset(0_0_100%_0)] data-[starting-style]:translate-y-[calc(-50%-8px)]",
  "data-[ending-style]:[clip-path:inset(0_0_100%_0)] data-[ending-style]:delay-0 data-[ending-style]:duration-200 data-[ending-style]:ease-(--notchset-ease-fold)",
  "motion-safe:data-[state=open]:animate-[notchset-unroll_420ms_var(--notchset-ease-unfold)_60ms_both] motion-safe:data-[state=closed]:animate-[notchset-rollup_200ms_var(--notchset-ease-travel)_both]",
  "max-sm:w-[calc(100%-2rem)]",
);

export const dialogCloseClass = cn(
  `notchset-focus`,
  "absolute end-3 top-3 flex size-8 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-foreground transition-colors duration-(--notchset-fade) ease-(--notchset-ease-fade) hover:bg-accent pointer-coarse:size-11",
);

export function CloseGlyph() {
  return (
    <svg aria-hidden width="12" height="12" viewBox="0 0 12 12">
      <path fill="none" d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.4" />
    </svg>
  );
}

export const dialogHeaderClass = "flex flex-col gap-1.5 border-b border-solid border-[var(--notchset-rule,var(--border))] ps-5 pe-14 pt-5 pb-4 text-start";
export const dialogFooterClass = "flex flex-col-reverse gap-2.5 px-5 pt-1 pb-5 sm:flex-row sm:justify-end [&>*]:max-sm:w-full";
export const dialogTitleClass = "m-0 font-sans text-lg leading-tight font-medium";
export const dialogDescriptionClass = "m-0 px-5 pt-4 font-sans text-sm leading-[1.5] text-muted-foreground";

/** The small mono line above the title ("PLANNER-V14 · STAGING → PROD"). */
export function DialogKicker({ className, ...props }: React.ComponentProps<"p">) {
  return <p data-slot="dialog-kicker" className={cn("m-0 font-mono text-[10px] tracking-[0.14em] text-muted-foreground uppercase", className)} {...props} />;
}

export type CompareRow = { metric: React.ReactNode; before: React.ReactNode; after: React.ReactNode; delta?: React.ReactNode };

/**
 * The comparison table: a header row (the population, then the before and after columns marked
 * with a hollow and a filled node, then the change), 32px rows, the new values settling in.
 */
export function DialogCompare({
  rows,
  population,
  beforeLabel,
  afterLabel,
  changeLabel = "CHANGE",
  className,
  ...props
}: Omit<React.ComponentProps<"table">, "children"> & { rows: readonly CompareRow[]; population: React.ReactNode; beforeLabel: React.ReactNode; afterLabel: React.ReactNode; changeLabel?: React.ReactNode }) {
  const cell = "h-8 px-2 text-start font-mono text-[11px] tracking-[0.04em] whitespace-nowrap tabular-nums first:ps-0 last:pe-0 last:text-end";
  return (
    <div className="mx-5 mt-4 overflow-x-auto">
      <table data-slot="dialog-compare" className={cn("w-full border-collapse border-t border-solid border-foreground", className)} {...props}>
        <thead>
          <tr className="border-b border-solid border-[var(--notchset-rule,var(--border))]">
            <th scope="col" className={cn(cell, "font-normal text-muted-foreground")}>
              {population}
            </th>
            <th scope="col" className={cn(cell, "font-normal text-muted-foreground")}>
              <span className="inline-flex items-center gap-1.5">
                <Node state="off" />
                {beforeLabel}
              </span>
            </th>
            <th scope="col" className={cn(cell, "font-normal text-foreground")}>
              <span className="inline-flex items-center gap-1.5">
                <Node state="on" />
                {afterLabel}
              </span>
            </th>
            <th scope="col" className={cn(cell, "font-normal text-muted-foreground")}>
              {changeLabel}
            </th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => (
            <tr key={i} className="border-b border-solid border-[var(--notchset-rule,var(--border))] last:border-b-0">
              <th scope="row" className={cn(cell, "font-medium text-foreground")}>
                {r.metric}
              </th>
              <td className={cn(cell, "text-muted-foreground")}>{r.before}</td>
              <td className={cn(cell, "text-foreground motion-safe:animate-[notchset-settle_380ms_var(--notchset-ease-settle)_both]")} style={{ animationDelay: `${480 + i * 90}ms` }}>
                {r.after}
              </td>
              <td className={cn(cell, "text-[var(--notchset-signal-text,var(--destructive))] motion-safe:animate-[notchset-settle_380ms_var(--notchset-ease-settle)_both]")} style={{ animationDelay: `${520 + i * 90}ms` }}>
                {r.delta}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

/**
 * Lets DialogConfirm close its dialog when its action is done. `session` counts the dialog's opens and
 * closes, so a close scheduled in one session never closes the next.
 */
export const DialogCloseContext = React.createContext<{ close: () => void; session: React.RefObject<number> } | null>(null);

/** The session counter for a Dialog root: it moves on every open or close, controlled ones included. */
export function useDialogSession(open: boolean | undefined) {
  const session = React.useRef(0);
  React.useEffect(() => {
    session.current += 1;
  }, [open]);
  return session;
}

/**
 * DialogConfirm's close: after its action settles, close the dialog `delay` ms later, but only if it is
 * still the session the action started in. A newer confirmation replaces the pending close; unmounting
 * cancels it.
 */
export function useConfirmClose(delay: number) {
  const ctx = React.useContext(DialogCloseContext);
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  return (done: Promise<unknown>) => {
    if (!ctx) return;
    const started = ctx.session.current;
    clearTimeout(timer.current);
    done.then(
      () => {
        if (ctx.session.current !== started) return;
        clearTimeout(timer.current);
        timer.current = setTimeout(() => {
          if (ctx.session.current === started) ctx.close();
        }, delay);
      },
      () => {},
    );
  };
}
```

### 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.
