# Number Field (Notchset): prompt.md (v1.0.0)

- id: `number-field` · 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://notchset.dev/r/number-field.json`
- npm dependencies: none
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json
- docs: https://notchset.dev/components/number-field
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A − value + field whose digits roll on strips, with hold-to-repeat keys, a scrubbable ruler and a running total; plus NumberNodes, a small range as nodes on a rule.

## 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/number-field.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: `NumberField`, `NumberNodes`, 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 { NumberField } from "@/components/ui/notchset/number-field";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `NumberField` | `number-field` | The field. |
| `NumberNodes` | `number-nodes` | The node picker. |

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/number-field.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
- number field, stepper, quantity, seats, spinbutton, number input, Notchset
- Seats, limits, quantities: a number people nudge

### Not when
- Free-form numbers like prices: use Input with inputMode=decimal

## Mistakes
- Pass valueText for anything the number alone doesn't say ("7 seats, $98 per month")

## Usage

```tsx
"use client";

import * as React from "react";
import { NumberField } from "@/components/ui/notchset/number-field";

export function SeatCount() {
  const [seats, setSeats] = React.useState(5);
  return <NumberField value={seats} onValueChange={setSeats} min={1} max={50} label="SEATS" unit={(v) => (v === 1 ? "SEAT" : "SEATS")} ruler />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `value / onValueChange` | `number / fn` |  | Controlled. |
| `min / max / step / largeStep` | `number` |  | Limits and steps (PgUp/PgDn use largeStep). |
| `unit / help / total` | `node or (value) => node` |  | The unit beside the value; the lines under it. |
| `ruler / rulerLabels` | `boolean / number[]` |  | The scrubbable ruler. |
| `NumberNodes` | `{ value, onValueChange, min, max, label, meta }` |  | A radio group of nodes for 0–5 style ranges. |

Full docs: https://notchset.dev/components/number-field

## 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 |
|---|---|
| ↑ / → and ↓ / ← | Step |
| PgUp / PgDn | Step by five |
| Home / End | Limits |

## Performance

- Digits are transforms; repeat and scrub write one state update per step.

## Responsive

- Fills its container; the ruler scales with it. Keys grow to 44px on touch screens.

## Motion inventory

| Interaction | What moves |
|---|---|
| Step | Digits roll on 0–9 strips (340ms settle); a tick per step |
| Hold | One step, then every 70ms after 380ms |
| Scrub | The ruler follows the pointer with no transition |

## Accessibility contract (preserve when editing)
- The value is a spinbutton with min, max, now and an optional valuetext
- ± keys are labelled and disabled at the limits
- NumberNodes is a radio group; arrows move and pick

## Install

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

```bash
npx shadcn@latest add https://notchset.dev/r/number-field.json
```

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/number-field.tsx

```tsx
"use client";

/**
 * Number Field (Notchset) v1.0.0 · Notchset
 * Docs: https://notchset.dev/components/number-field
 * MIT licensed: free to use, change and share.
 */

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

/*
 * Notchset Number Field (no primitive, one file for both builds). A 40px field in three
 * parts, − | value | +, whose digits roll on 0–9 strips (340ms settle). Hold a key to step once, then
 * every 70ms after 380ms; at a limit the key greys and refuses. The value is a spinbutton: ↑/→ and
 * ↓/← step, PgUp/PgDn step by five, Home/End jump to the limits. Under it, an optional ruler you can
 * scrub (ticks at or below the value ink, a run and a target node). NumberNodes is the small-range
 * form: a radio group of nodes on a rule, the run filling to the chosen one.
 */

const ROW = 22;
/** The ruler's drawing box (viewBox 360 wide); ticks run from x 4 to x 356. */
const RULER_WIDTH = 360;
const RULER_START = 4;
const RULER_SPAN = 352;
/** At most this many ticks: a fine step coarsens the ruler, not the value. */
const MAX_TICKS = 101;

/** Decimal places in a number as written, including 1e-7 style. */
function decimals(n: number) {
  const [mantissa = "", exponent] = String(n).split("e-");
  return (mantissa.split(".")[1] ?? "").length + (exponent ? Number(exponent) : 0);
}

function Digits({ value }: { value: number }) {
  return (
    <span aria-hidden className="flex h-[22px] overflow-hidden font-mono text-[22px] leading-[22px] font-light tracking-[-0.02em] tabular-nums">
      {String(value)
        .split("")
        .map((d, i, all) => (
          <span
            key={all.length - i}
            className="flex flex-col transition-transform duration-[340ms] ease-[cubic-bezier(.34,1.3,.5,1)]"
            style={{ transform: /\d/.test(d) ? `translateY(${-Number(d) * ROW}px)` : undefined }}
          >
            {/\d/.test(d) ? Array.from({ length: 10 }, (_, n) => <span key={n} className="h-[22px]">{n}</span>) : <span className="h-[22px]">{d}</span>}
          </span>
        ))}
    </span>
  );
}

function NumberField({
  value,
  onValueChange,
  min = 0,
  max = 100,
  step: stepProp = 1,
  largeStep = 5,
  label,
  meta,
  unit,
  ruler = false,
  rulerLabels,
  help,
  total,
  decrementLabel = "Decrease",
  incrementLabel = "Increase",
  valueText,
  className,
  "aria-label": ariaLabel,
}: {
  value: number;
  onValueChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  largeStep?: number;
  label?: React.ReactNode;
  /** The mono note opposite the label: "TEAM PLAN · 1–50". */
  meta?: React.ReactNode;
  /** The muted unit beside the value; a function gets the value (SEAT / SEATS). */
  unit?: React.ReactNode | ((value: number) => React.ReactNode);
  /** Show the scrubbable ruler under the field. */
  ruler?: boolean;
  /** Labelled values under the ruler (min, the tens, max by default). */
  rulerLabels?: readonly number[];
  /** The line under it, left: price, limits, a warning (string or a function of the value). */
  help?: React.ReactNode | ((value: number) => React.ReactNode);
  /** The line under it, right: a running total. */
  total?: React.ReactNode | ((value: number) => React.ReactNode);
  decrementLabel?: string;
  incrementLabel?: string;
  /** What a screen reader hears: "7 seats, $98 per month". */
  valueText?: (value: number) => string;
  className?: string;
  /** The field's name when there is no visible label. */
  "aria-label"?: string;
}) {
  // A zero, negative or non-finite step would loop forever or divide by zero: fall back to 1.
  const step = Number.isFinite(stepProp) && stepProp > 0 ? stepProp : 1;
  const uid = React.useId();
  const [press, setPress] = React.useState(0);
  const [scrub, setScrub] = React.useState(false);
  const [focus, setFocus] = React.useState(false);
  const live = React.useRef(value);
  React.useEffect(() => {
    live.current = value;
  }, [value]);
  const timers = React.useRef<{ t?: ReturnType<typeof setTimeout>; i?: ReturnType<typeof setInterval> }>({});
  const stop = () => {
    clearTimeout(timers.current.t);
    clearInterval(timers.current.i);
    setPress(0);
  };
  React.useEffect(() => () => {
    clearTimeout(timers.current.t);
    clearInterval(timers.current.i);
  }, []);

  const clamp = (v: number) => Math.max(min, Math.min(max, v));
  const set = (v: number, el?: Element | null) => {
    // Steps count from min (min 1, step 2: 1, 3, 5…), rounded to the finer of min's and step's decimals
    // (0.1 + 0.2 reads 0.3).
    const dec = Math.min(20, Math.max(decimals(step), decimals(min)));
    const next = clamp(Number((min + Math.round((v - min) / step) * step).toFixed(dec)));
    if (next === live.current) return;
    live.current = next;
    playCue(el ?? null, "tick");
    onValueChange(next);
  };
  // The repeat reads the latest set (and so the latest bounds, step and callback), not the one from when the hold began.
  const latest = useLatest({ set, step });
  const fromPointer = React.useRef(false);
  const hold = (d: number, el: Element) => {
    fromPointer.current = true;
    set(live.current + d * step, el);
    stop();
    setPress(d);
    timers.current.t = setTimeout(() => {
      timers.current.i = setInterval(() => latest.current.set(live.current + d * latest.current.step, el), 70);
    }, 380);
  };

  const atMin = value <= min;
  const atMax = value >= max;
  const show = <T,>(x: T | ((v: number) => T)) => (typeof x === "function" ? (x as (v: number) => T)(value) : x);

  const span = max - min || 1;
  const rulerX = (v: number) => RULER_START + ((v - min) / span) * RULER_SPAN;
  const fromX = (clientX: number, el: SVGSVGElement) => {
    const box = el.getBoundingClientRect();
    const x = ((clientX - box.left) / box.width) * RULER_WIDTH;
    set(min + ((x - RULER_START) / RULER_SPAN) * span, el);
  };
  // One tick per step, or per several steps when that would pass MAX_TICKS.
  const tickEvery = Math.max(1, Math.ceil(Math.round(span / step) / (MAX_TICKS - 1)));
  const tickCount = Math.floor(Math.round(span / step) / tickEvery) + 1;
  // Five evenly spaced labels by default, each on a step.
  const labels = rulerLabels ?? [...new Set(Array.from({ length: 5 }, (_, i) => Number((min + Math.round(((span / step) * i) / 4) * step).toFixed(Math.min(20, Math.max(decimals(step), decimals(min)))))))];

  const key = (d: number, disabled: boolean, aria: string) => (
    <button
      {...OWN_SOUND}
      type="button"
      aria-label={aria}
      aria-controls={uid}
      disabled={disabled}
      onPointerDown={(e) => {
        if (e.button || disabled) return;
        hold(d, e.currentTarget);
      }}
      onPointerUp={stop}
      onPointerLeave={(e) => {
        // A press dragged away while still down produces no click, so the flag must not wait for one.
        // (Touch leaves after pointer-up but before its click: buttons is 0 then, and the flag stays.)
        if (e.buttons !== 0) fromPointer.current = false;
        stop();
      }}
      onPointerCancel={() => {
        fromPointer.current = false;
        stop();
      }}
      onContextMenu={(e) => e.preventDefault()}
      // Keyboard, assistive and programmatic clicks step once; the click that ends a pointer hold
      // was already counted on pointer-down.
      onClick={(e) => {
        // Only a real pointer click (detail > 0) right after a counted pointer-down is skipped.
        const counted = fromPointer.current && e.detail > 0;
        fromPointer.current = false;
        if (counted) return;
        set(live.current + d * step, e.currentTarget);
      }}
      className={cn(
        `notchset-focus`,
        "[--notchset-focus-inset:-3px] flex w-10 flex-none cursor-pointer items-center justify-center border-0 border-solid border-[var(--notchset-rule,var(--border))] p-0 text-foreground transition-[background-color] duration-(--notchset-fade) ease-(--notchset-ease-fade) select-none disabled:cursor-not-allowed disabled:text-[var(--notchset-rule,var(--border))] pointer-coarse:w-11",
        d < 0 ? "border-e" : "border-s",
        press === d ? "bg-accent" : "bg-transparent",
      )}
    >
      <svg aria-hidden width="12" height="12" viewBox="0 0 12 12">
        <path fill="none" d={d < 0 ? "M2 6 H10" : "M2 6 H10 M6 2 V10"} stroke="currentColor" strokeWidth="1.3" />
      </svg>
    </button>
  );

  return (
    <div {...NOTCHSET_ROOT} data-slot="number-field-root" className={cn("flex flex-col gap-2.5", className)}>
      {(label || meta) && (
        <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
          <span id={`${uid}-label`} className="font-mono text-[11px] font-medium tracking-[0.1em] whitespace-nowrap text-foreground">
            {label}
          </span>
          {meta && <span className="font-mono text-[10px] tracking-[0.06em] whitespace-nowrap text-muted-foreground">{meta}</span>}
        </div>
      )}
      <div
        data-slot="number-field"
        className={cn(
          "flex h-10 border border-solid bg-background transition-[border-color] duration-(--notchset-fade) ease-(--notchset-ease-fade) pointer-coarse:h-11",
          focus ? "border-foreground" : "border-[var(--notchset-control-edge,var(--input))]",
        )}
      >
        {key(-1, atMin, decrementLabel)}
        <div
          id={uid}
          role="spinbutton"
          tabIndex={0}
          aria-labelledby={label ? `${uid}-label` : undefined}
          aria-label={label ? undefined : ariaLabel}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
          aria-valuetext={valueText?.(value)}
          onFocus={() => setFocus(true)}
          onBlur={() => setFocus(false)}
          onKeyDown={(e) => {
            const m: Record<string, number> = { ArrowUp: step, ArrowRight: step, ArrowDown: -step, ArrowLeft: -step, PageUp: largeStep * step, PageDown: -largeStep * step };
            if (m[e.key] !== undefined) {
              e.preventDefault();
              set(value + m[e.key]!, e.currentTarget);
            } else if (e.key === "Home") {
              e.preventDefault();
              set(min, e.currentTarget);
            } else if (e.key === "End") {
              e.preventDefault();
              set(max, e.currentTarget);
            }
          }}
          className={cn(`notchset-focus`, "[--notchset-focus-inset:-3px] flex flex-1 items-center justify-center gap-[9px] text-foreground outline-none")}
        >
          <Digits value={value} />
          {unit !== undefined && <span className="font-mono text-[11px] tracking-[0.1em] text-muted-foreground">{show(unit)}</span>}
        </div>
        {key(1, atMax, incrementLabel)}
      </div>
      {ruler && (
        <svg
          aria-hidden
          width="100%"
          height="36"
          viewBox="0 0 360 36"
          preserveAspectRatio="none"
          className="block cursor-ew-resize touch-none overflow-visible"
          onPointerDown={(e) => {
            e.currentTarget.setPointerCapture?.(e.pointerId);
            setScrub(true);
            fromX(e.clientX, e.currentTarget);
          }}
          onPointerMove={(e) => scrub && fromX(e.clientX, e.currentTarget)}
          onPointerUp={() => setScrub(false)}
          onPointerCancel={() => setScrub(false)}
        >
          <rect x="0" y="0" width="360" height="36" fill="transparent" />
          <line x1="4" y1="22.5" x2="356" y2="22.5" strokeWidth="1" className="stroke-[var(--notchset-control-edge,var(--input))]" />
          {Array.from({ length: tickCount }, (_, i) => {
            const v = min + i * tickEvery * step;
            // Long ticks on round values (10, 20 … in tick units), medium on fives, and at the start.
            const unit = Math.round(v / (step * tickEvery));
            const y = i === 0 || unit % 10 === 0 ? 13 : unit % 5 === 0 ? 16 : 19;
            return <line key={i} x1={rulerX(v)} y1={y} x2={rulerX(v)} y2="22.5" strokeWidth="1" className={v <= value ? "stroke-foreground" : "stroke-[var(--notchset-control-edge,var(--input))]"} />;
          })}
          <g className={cn("[&>*]:transition-all [&>*]:duration-300 [&>*]:ease-[cubic-bezier(.34,1.3,.5,1)]", scrub && "[&>*]:transition-none")}>
            <line x1="4" y1="22.5" x2={rulerX(value)} y2="22.5" strokeWidth="2" className="stroke-foreground" />
            <line x1={rulerX(value)} y1="9" x2={rulerX(value)} y2="22.5" strokeWidth="1" className="stroke-foreground" />
            <circle cx={rulerX(value)} cy="5.5" r="3.4" strokeWidth="1" className="fill-background stroke-foreground" />
            <circle cx={rulerX(value)} cy="5.5" r="1.4" className="fill-foreground" />
          </g>
          {labels.map((n) => (
            <text key={n} x={rulerX(n)} y="34" textAnchor="middle" className="fill-muted-foreground font-mono text-[10px]">
              {n}
            </text>
          ))}
        </svg>
      )}
      {(help !== undefined || total !== undefined) && (
        <div className="flex flex-wrap items-baseline justify-between gap-3 font-mono text-[11px] tracking-[0.04em] whitespace-nowrap gap-y-1">
          <span className={atMax ? "text-[var(--notchset-signal-text,var(--destructive))]" : "text-muted-foreground"}>{show(help)}</span>
          {total !== undefined && <span className="tabular-nums">{show(total)}</span>}
        </div>
      )}
    </div>
  );
}

/** A small range as nodes on a rule: a radio group whose run fills to the chosen node. */
function NumberNodes({
  value,
  onValueChange,
  min = 0,
  max = 5,
  label,
  meta,
  itemLabel = (n) => String(n),
  className,
}: {
  value: number;
  onValueChange: (value: number) => void;
  min?: number;
  max?: number;
  label?: React.ReactNode;
  meta?: React.ReactNode;
  itemLabel?: (n: number) => string;
  className?: string;
}) {
  const uid = React.useId();
  const group = React.useRef<HTMLDivElement>(null);
  // Whole bounds, lo ≤ hi, at most 24 nodes; the value is held inside them so one node always has the tab stop.
  const lo = Number.isFinite(min) ? Math.trunc(min) : 0;
  const hi = Math.min(lo + 23, Math.max(lo, Number.isFinite(max) ? Math.trunc(max) : lo + 5));
  const n = hi - lo;
  const current = Math.max(lo, Math.min(hi, Number.isFinite(value) ? Math.round(value) : lo));
  const pick = (v: number, el: Element | null) => {
    const next = Math.max(lo, Math.min(hi, v));
    if (next === current) return;
    playCue(el, "tick");
    onValueChange(next);
  };
  return (
    <div {...NOTCHSET_ROOT} data-slot="number-nodes" className={cn("flex flex-col gap-3", className)}>
      {(label || meta) && (
        <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
          <span id={`${uid}-label`} className="font-mono text-[11px] font-medium tracking-[0.1em] whitespace-nowrap text-foreground">
            {label}
          </span>
          {meta && <span className="font-mono text-[10px] tracking-[0.06em] whitespace-nowrap text-muted-foreground">{meta}</span>}
        </div>
      )}
      <div
        ref={group}
        role="radiogroup"
        aria-labelledby={label ? `${uid}-label` : undefined}
        onKeyDown={(e) => {
          const d = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
          if (!d) return;
          e.preventDefault();
          const next = Math.max(lo, Math.min(hi, current + d));
          pick(next, e.currentTarget);
          group.current?.querySelectorAll<HTMLElement>("[role=radio]")[next - lo]?.focus();
        }}
        className="relative mb-3.5 flex h-7 items-center justify-between"
      >
        <span aria-hidden className="absolute inset-x-3.5 top-[13.5px] h-px bg-[var(--notchset-rule,var(--border))]" />
        <span aria-hidden className="absolute start-3.5 top-[13px] h-0.5 bg-foreground transition-[width] duration-[360ms] ease-[var(--notchset-ease-unfold)]" style={{ width: `calc((100% - 28px) * ${n ? (current - lo) / n : 0})` }} />
        {Array.from({ length: n + 1 }, (_, i) => {
          const v = lo + i;
          const on = v === current;
          return (
            <button
              key={v}
              {...OWN_SOUND}
              type="button"
              role="radio"
              aria-checked={on}
              aria-label={itemLabel(v)}
              tabIndex={on ? 0 : -1}
              onClick={(e) => pick(v, e.currentTarget)}
              className={cn(`notchset-focus`, "[--notchset-focus-inset:-4px] relative flex size-7 cursor-pointer items-center justify-center border-0 bg-transparent p-0")}
            >
              <svg aria-hidden width="13" height="13" viewBox="0 0 13 13" className="overflow-visible">
                <circle cx="6.5" cy="6.5" r="5" strokeWidth="1" className={cn("fill-background transition-[stroke] duration-(--notchset-fade) ease-(--notchset-ease-fade)", v <= current ? "stroke-foreground" : "stroke-[var(--notchset-control-edge,var(--input))]")} />
                <circle
                  cx="6.5"
                  cy="6.5"
                  className={cn("fill-foreground transition-[r] duration-[280ms] ease-[var(--notchset-ease-bloom)]", on ? "[r:2.6px]" : v < current ? "[r:1.4px]" : "[r:0px]")}
                  style={{ transitionDelay: `${Math.abs(v - current) * 20}ms` }}
                />
              </svg>
              <span aria-hidden className={cn("absolute top-[30px] font-mono text-[10px]", on ? "text-foreground" : "text-muted-foreground")}>
                {v}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

export { NumberField, NumberNodes };
```

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