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

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

A searchable picker that completes inline as you type, with avatars and presence, the match underlined, a load meter per row, and a help line about the choice.

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

```tsx
import { Combobox, type ComboboxItem } from "@/components/ui/notchset/combobox";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Combobox` | `combobox` | The field. |
| `list` | `combobox-root` | Field, list and help. |

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

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

## Match the original
- Read `components/ui/notchset/combobox.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
- combobox, autocomplete, assign, owner picker, people picker, typeahead, Notchset
- Picking one of many, by typing: people, projects, models

### Not when
- A handful of options: use Select

## Mistakes
- Keep labels unique: completion picks the first label that starts with what was typed

## Usage

```tsx
"use client";

import * as React from "react";
import { Combobox, type ComboboxItem } from "@/components/ui/notchset/combobox";

const people: ComboboxItem[] = [
  { value: "maya", label: "Maya Kerr", initials: "MK", meta: "ML LEAD", presence: "online", load: 2 },
  { value: "jonas", label: "Jonas Weber", initials: "JW", meta: "EVALS", presence: "away", load: 4 },
  { value: "ana", label: "Ana Ruiz", initials: "AR", meta: "INFRA", presence: "offline", load: 1 },
];

export function EvalOwner() {
  const [owner, setOwner] = React.useState<string | null>("maya");
  return <Combobox items={people} value={owner} onValueChange={setOwner} label="EVAL OWNER" />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `items` | `{ value, label, initials?, meta?, presence?, load? }[]` |  | The options. |
| `value / onValueChange` | `string \| null` |  | Controlled. |
| `help` | `(item, draft) => { text, tone }` |  | The line under it. |
| `maxLoad` | `number` |  | Dots in the load meter (5); full turns signal. |

Full docs: https://beautiful-ui.dev/components/combobox

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

## Keyboard

| Keys | Action |
|---|---|
| Type | Filter and complete |
| ↑ / ↓ | Move |
| Enter / Tab | Accept |
| Esc | Revert |

## Performance

- Filtering is synchronous over your items; five rows show, the rest scroll.

## Responsive

- Fills its container; rows truncate names and keep the meter.

## Motion inventory

| Interaction | What moves |
|---|---|
| Open | The leader drops and the list unrolls (360ms); rows drop in 35ms apart |
| Highlight | One block glides between rows (160ms) |

## Accessibility contract (preserve when editing)
- role=combobox with aria-autocomplete=both and aria-activedescendant
- Blur or Esc reverts to the committed value; nothing changes until you pick

## Install

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

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

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/combobox.tsx

```tsx
"use client";

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

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

/*
 * Notchset Combobox (handoff 1.10; no primitive, one file for both builds). A 40px field whose leading
 * slot shows the chosen person's avatar (a magnifier otherwise). Focus selects the text so typing
 * replaces it. Typing completes inline (aria-autocomplete="both"): when a label starts with what you
 * typed, the rest is filled in and selected, so the next key overwrites it; deleting never completes.
 * The list shows five 44px rows and scrolls: avatar with presence, the label with the match underlined,
 * the role, a five-dot load meter (signal when full) and a 2px bar on the chosen row. Tab or Enter
 * accepts; blur or Esc reverts to the committed value.
 */

export type ComboboxItem = {
  value: string;
  label: string;
  initials?: string;
  meta?: string;
  presence?: "online" | "away" | "offline";
  /** 0–5: open work, drawn as dots; at the maximum they turn signal. */
  load?: number;
};

const ROW = 44;

function Combobox({
  items,
  value,
  onValueChange,
  label,
  meta,
  placeholder = "Type a name",
  help,
  maxLoad = 5,
  labels,
  className,
}: {
  items: readonly ComboboxItem[];
  value: string | null;
  onValueChange: (value: string | null) => void;
  label?: React.ReactNode;
  meta?: React.ReactNode;
  placeholder?: string;
  /** The line under the field, from the chosen item (null when none). */
  help?: (item: ComboboxItem | null, draft: string) => { text: React.ReactNode; tone?: "muted" | "ink" | "signal" };
  maxLoad?: number;
  labels?: { none?: (q: string) => string; clear?: string; show?: string; footer?: readonly string[]; loadOpen?: (n: number) => string; loadFree?: string };
  className?: string;
}) {
  const uid = React.useId();
  const input = React.useRef<HTMLInputElement>(null);
  const list = React.useRef<HTMLDivElement>(null);
  const chosen = items.find((i) => i.value === value) ?? null;
  const [draft, setDraft] = React.useState(chosen?.label ?? "");
  const [typed, setTyped] = React.useState("");
  const [open, setOpen] = React.useState(false);
  const [focus, setFocus] = React.useState(false);
  const [h, setH] = React.useState(0);
  const sel = React.useRef<[number, number] | null>(null);
  React.useLayoutEffect(() => {
    if (sel.current && input.current) {
      input.current.setSelectionRange(sel.current[0], sel.current[1]);
      sel.current = null;
    }
  });
  // Follow the committed value when it changes from outside.
  const [seen, setSeen] = React.useState(value);
  if (seen !== value) {
    setSeen(value);
    setDraft(chosen?.label ?? "");
  }

  const ty = typed.trim().toLowerCase();
  const shown = items.filter((i) => !ty || i.label.toLowerCase().includes(ty));
  const hi = Math.min(h, Math.max(0, shown.length - 1));
  const matched = !!chosen && draft === chosen.label;

  const revert = () => {
    setOpen(false);
    setTyped("");
    setDraft(chosen?.label ?? "");
  };
  const pick = (it: ComboboxItem) => {
    playCue(input.current, "tick");
    setDraft(it.label);
    setTyped("");
    setOpen(false);
    onValueChange(it.value);
  };
  const scrollTo = (n: number) => {
    const L = list.current;
    if (!L) return;
    const top = 4 + n * ROW;
    if (top < L.scrollTop) L.scrollTop = top - 4;
    else if (top + ROW > L.scrollTop + L.clientHeight) L.scrollTop = top + ROW + 4 - L.clientHeight;
  };
  const L = { none: (q: string) => `NO ONE MATCHES “${q.toUpperCase()}”`, clear: "Clear", show: "Show options", footer: ["↵ ASSIGN", "TAB COMPLETES", "ESC"], loadOpen: (n: number) => `${n} OPEN`, loadFree: "FREE", ...labels };
  const h2 = help?.(matched ? chosen : null, draft);

  return (
    <div {...NOTCHSET_ROOT} data-slot="combobox-root" className={cn("flex flex-col gap-2", className)}>
      {(label || meta) && (
        <div className="flex items-baseline justify-between gap-3">
          <label htmlFor={uid} className="font-mono text-[9.5px] font-medium tracking-[0.1em] text-foreground">
            {label}
          </label>
          {meta && <span className="font-mono text-[8.5px] tracking-[0.06em] whitespace-nowrap text-muted-foreground">{meta}</span>}
        </div>
      )}
      <div className="relative">
        <div
          data-slot="combobox"
          className={cn("flex h-10 items-center border border-solid bg-background transition-[border-color] duration-[120ms] ease-linear pointer-coarse:h-11", open || focus ? "border-foreground" : "border-[var(--notchset-control-edge,var(--input))]")}
        >
          <span className="flex w-10 flex-none items-center justify-center">
            {matched && chosen?.initials ? (
              <span className="flex size-[22px] items-center justify-center rounded-full bg-foreground font-mono text-[7.5px] font-medium tracking-[0.02em] text-background">{chosen.initials}</span>
            ) : (
              <svg aria-hidden width="13" height="13" viewBox="0 0 13 13" className="text-muted-foreground">
                <circle cx="5.5" cy="5.5" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
                <path d="M8.6 8.6 L12 12" stroke="currentColor" strokeWidth="1.2" />
              </svg>
            )}
          </span>
          <input
            ref={input}
            id={uid}
            role="combobox"
            aria-expanded={open}
            aria-controls={`${uid}-list`}
            aria-activedescendant={open && shown[hi] ? `${uid}-o-${shown[hi].value}` : undefined}
            aria-autocomplete="both"
            value={draft}
            placeholder={placeholder}
            spellCheck={false}
            autoComplete="off"
            onFocus={(e) => {
              e.currentTarget.select();
              setFocus(true);
              setOpen(true);
              setH(Math.max(0, shown.findIndex((x) => x.value === value)));
            }}
            onBlur={() => {
              setFocus(false);
              revert();
            }}
            onChange={(e) => {
              const v = e.target.value;
              const insert = /^insert/.test((e.nativeEvent as InputEvent).inputType ?? "");
              let next = v;
              let n = 0;
              if (insert && v) {
                const lv = v.toLowerCase();
                const m = items.find((i) => i.label.toLowerCase().startsWith(lv));
                if (m) {
                  next = m.label;
                  sel.current = [v.length, next.length];
                  const filtered = items.filter((i) => i.label.toLowerCase().includes(lv.trim()));
                  n = Math.max(0, filtered.findIndex((x) => x.value === m.value));
                }
              }
              setTyped(v);
              setDraft(next);
              setOpen(true);
              setH(n);
              if (list.current) list.current.scrollTop = 0;
            }}
            onKeyDown={(e) => {
              if (e.key === "ArrowDown" || e.key === "ArrowUp") {
                e.preventDefault();
                if (!open) return setOpen(true);
                if (!shown.length) return;
                const n = (hi + (e.key === "ArrowDown" ? 1 : -1) + shown.length) % shown.length;
                setH(n);
                scrollTo(n);
              } else if (e.key === "Enter") {
                e.preventDefault();
                if (open && shown[hi]) pick(shown[hi]);
              } else if (e.key === "Tab" && open) {
                const exact = items.find((i) => i.label === draft) ?? shown[hi];
                if (exact && draft) pick(exact);
              } else if (e.key === "Escape") {
                e.preventDefault();
                revert();
              }
            }}
            className="h-full min-w-0 flex-1 border-0 bg-transparent p-0 font-sans text-[13.5px] font-medium text-foreground outline-none placeholder:font-normal placeholder:text-muted-foreground"
          />
          {draft && (
            <button
              {...OWN_SOUND}
              type="button"
              tabIndex={-1}
              aria-label={L.clear}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => {
                setDraft("");
                setTyped("");
                setOpen(true);
                setH(0);
                onValueChange(null);
                input.current?.focus();
              }}
              className="flex size-7 flex-none cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-muted-foreground hover:text-foreground"
            >
              <svg aria-hidden width="10" height="10" viewBox="0 0 10 10">
                <path d="M1.5 1.5 L8.5 8.5 M8.5 1.5 L1.5 8.5" stroke="currentColor" strokeWidth="1.2" />
              </svg>
            </button>
          )}
          <button
            {...OWN_SOUND}
            type="button"
            tabIndex={-1}
            aria-label={L.show}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => (open ? revert() : input.current?.focus())}
            className="flex h-[38px] w-[34px] flex-none cursor-pointer items-center justify-center border-0 border-s border-solid border-[var(--notchset-rule,var(--border))] bg-transparent p-0 text-foreground pointer-coarse:h-[42px] pointer-coarse:w-11"
          >
            <svg aria-hidden width="9" height="9" viewBox="0 0 12 12" className={cn("transition-transform duration-300 ease-[cubic-bezier(.34,1.5,.5,1)]", open && "rotate-180")}>
              <path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" />
            </svg>
          </button>
        </div>
        <span aria-hidden className={cn("absolute start-5 top-10 h-2 w-px origin-top bg-foreground transition-transform", open ? "scale-y-100 duration-100 ease-[var(--notchset-ease-travel)]" : "scale-y-0 delay-100 duration-[80ms] ease-linear")} />
        <div
          id={`${uid}-list`}
          role="listbox"
          aria-label={typeof label === "string" ? label : "Options"}
          className={cn(
            "absolute inset-x-0 top-12 z-40 grid border border-solid border-foreground bg-background transition-[grid-template-rows,visibility]",
            open ? "visible grid-rows-[1fr] delay-[80ms] duration-[360ms] ease-[var(--notchset-ease-unfold)]" : "invisible grid-rows-[0fr] duration-[160ms] ease-[var(--notchset-ease-travel)]",
          )}
        >
          <div className="min-h-0 overflow-hidden">
            <div ref={list} className="max-h-[228px] overflow-y-auto [scrollbar-color:var(--notchset-control-edge,var(--input))_transparent] [scrollbar-width:thin]">
              <div className="relative py-1">
                <span aria-hidden className="absolute inset-x-0 h-11 bg-[var(--notchset-faceplate,var(--card))] transition-[top,opacity] duration-[160ms,120ms] ease-[cubic-bezier(.2,0,0,1),linear]" style={{ top: 4 + hi * ROW, opacity: shown.length ? 1 : 0 }} />
                {shown.map((it, j) => {
                  const on = it.value === value;
                  const q = typed.trim();
                  const k = q ? it.label.toLowerCase().indexOf(q.toLowerCase()) : -1;
                  const load = it.load ?? 0;
                  const full = load >= maxLoad;
                  return (
                    <div
                      key={it.value}
                      id={`${uid}-o-${it.value}`}
                      role="option"
                      aria-selected={on}
                      onMouseDown={(e) => {
                        e.preventDefault();
                        pick(it);
                      }}
                      onMouseEnter={() => setH(j)}
                      className="relative flex h-11 cursor-pointer items-center gap-[11px] px-3 text-foreground motion-safe:animate-[notchset-fade-in_160ms_linear_both,notchset-lift-down_340ms_cubic-bezier(.34,1.5,.5,1)_both]"
                      style={{ animationDelay: `${140 + Math.min(j, 6) * 35}ms` }}
                    >
                      <span aria-hidden className={cn("absolute start-0 top-2.5 bottom-2.5 w-0.5 bg-foreground transition-transform duration-[260ms] ease-[cubic-bezier(.34,1.3,.5,1)]", on ? "scale-y-100" : "scale-y-0")} />
                      {it.initials && (
                        <span className={cn("relative flex size-[26px] flex-none items-center justify-center rounded-full border border-solid border-foreground font-mono text-[7.5px] font-medium", on ? "bg-foreground text-background" : "bg-background text-foreground")}>
                          {it.initials}
                          {it.presence && it.presence !== "offline" && (
                            <svg aria-hidden width="10" height="10" viewBox="0 0 10 10" className="absolute -end-[3px] -bottom-[3px]">
                              <circle cx="5" cy="5" r="4.2" className="fill-background" />
                              <circle cx="5" cy="5" r="2.6" strokeWidth="1" className={cn("stroke-foreground", it.presence === "online" ? "fill-foreground" : "fill-background")} />
                            </svg>
                          )}
                        </span>
                      )}
                      <span className="flex min-w-0 flex-1 flex-col gap-[3px]">
                        <span className="truncate font-sans text-[13px] font-medium">
                          {k < 0 ? (
                            it.label
                          ) : (
                            <>
                              {it.label.slice(0, k)}
                              <span className="font-bold underline decoration-1 underline-offset-[3px]">{it.label.slice(k, k + q.length)}</span>
                              {it.label.slice(k + q.length)}
                            </>
                          )}
                        </span>
                        {it.meta && <span className="font-mono text-[8.5px] tracking-[0.05em] whitespace-nowrap text-muted-foreground uppercase">{it.meta}</span>}
                      </span>
                      {it.load !== undefined && (
                        <span className="flex flex-none items-center gap-[7px]">
                          <svg aria-hidden width="34" height="6" viewBox="0 0 34 6" className="overflow-visible">
                            {Array.from({ length: maxLoad }, (_, d) => (
                              <circle
                                key={d}
                                cx={3 + d * 7}
                                cy="3"
                                r="2.3"
                                strokeWidth="1"
                                className={d < load ? (full ? "fill-[var(--notchset-signal,var(--destructive))] stroke-[var(--notchset-signal,var(--destructive))]" : "fill-foreground stroke-foreground") : "fill-background stroke-[var(--notchset-control-edge,var(--input))]"}
                              />
                            ))}
                          </svg>
                          <span className={cn("w-[42px] text-end font-mono text-[8px] tracking-[0.04em] whitespace-nowrap", full ? "text-[var(--notchset-signal-text,var(--destructive))]" : "text-muted-foreground")}>
                            {load ? L.loadOpen(load) : L.loadFree}
                          </span>
                        </span>
                      )}
                    </div>
                  );
                })}
                {!shown.length && <div className="flex h-11 items-center px-3.5 font-mono text-[9px] tracking-[0.04em] text-muted-foreground">{L.none(typed.trim())}</div>}
              </div>
            </div>
            <div className="flex h-7 items-center gap-3.5 border-t border-solid border-[var(--notchset-rule,var(--border))] px-3.5 font-mono text-[8px] tracking-[0.1em] whitespace-nowrap text-muted-foreground">
              {L.footer.map((f) => (
                <span key={f}>{f}</span>
              ))}
            </div>
          </div>
        </div>
      </div>
      {h2 && (
        <span
          aria-live="polite"
          className={cn(
            "font-mono text-[9px] tracking-[0.04em] whitespace-nowrap",
            h2.tone === "signal" ? "text-[var(--notchset-signal-text,var(--destructive))]" : h2.tone === "ink" ? "text-foreground" : "text-muted-foreground",
          )}
        >
          {h2.text}
        </span>
      )}
    </div>
  );
}

export { Combobox };
```

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

```tsx
"use client";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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