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

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

shadcn's Pagination parts in Notchset, and with a total, the instrument: a range header that flips, prev and next keys, a seven-slot window whose ink block slides under the current page, ellipses that jump three pages, and a ruler whose node glides to the position.

## 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/pagination.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: `Pagination`, `PaginationContent`, `PaginationEllipsis`, `PaginationItem`, `PaginationLink`, `PaginationNext`, `PaginationPrevious`, `pageWindow`, 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 { Pagination } from "@/components/ui/notchset/pagination";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Pagination` | `pagination` | The nav. |
| `List` | `pagination-list` | The window of pages. |
| `Ruler` | `pagination-ruler` | The position ruler. |

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

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `SoundProvider` once (install: `npx shadcn@latest add https://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/pagination.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
- pagination, pager, pages, table pages, runs pages, shadcn pagination, Notchset
- Paging a table or list
- A drop-in for shadcn's Pagination parts

### Not when
- Infinite feeds: load more as you scroll

## Mistakes
- Pass count and perPage for the range header, or it shows the page alone

## Usage

```tsx
"use client";

import * as React from "react";
import { Pagination } from "@/components/ui/notchset/pagination";

export function RunsPager() {
  const [page, setPage] = React.useState(1);
  return <Pagination aria-label="Runs pages" total={22} page={page} onPageChange={setPage} perPage={10} count={214} itemLabel="Runs" />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `total` | `number` |  | Pages in all; makes Pagination the instrument. |
| `page / defaultPage / onPageChange` | `number / (page) => void` |  | The current page. |
| `perPage / count / itemLabel` | `number / number / string` |  | The range header ("RUNS 11–20 OF 214"). |
| `siblings` | `number` |  | Pages beside the current one. Default 1 (seven slots). |
| `ruler` | `boolean` |  | The position ruler. Default true. |
| `PaginationLink isActive` | `boolean` |  | As shadcn. |

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

## 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 |
|---|---|
| ← → | Previous or next page |
| Home / End | First or last page |

## Performance

- Pages are plain buttons; the page number flips text only when it changes.
- The slot list is computed from total and page, nothing measured.

## Responsive

- Collapses to a gap node between page groups so it fits narrow widths.
- 44px cells on touch screens.

## Motion inventory

| Interaction | What moves |
|---|---|
| Change | The ink block slides 340ms cubic-bezier(.34,1.25,.5,1); the label turns on-ink after 110ms; the ruler's node glides 420ms |
| Window | Slot labels flip when the window shifts |
| Keys | The arrow nudges 2px outward on hover |

## Accessibility contract (preserve when editing)
- A <nav> with a label; the current page has aria-current="page"
- The range header is aria-live; ellipses are hidden from screen readers
- Prev and next at the ends are aria-disabled

## Install

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

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

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/pagination.tsx

```tsx
"use client";

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

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

/*
 * Notchset Pagination. shadcn's parts (Pagination, PaginationContent, PaginationItem, PaginationLink,
 * PaginationPrevious, PaginationNext, PaginationEllipsis) in Notchset: square 38px cells in mono, the
 * current page an ink block. Give Pagination a `total` and it is the instrument: a range header
 * that flips as it changes, prev and next keys, a seven-slot window with an ink block that slides
 * under the current page, ellipses that jump three pages, and a ruler whose node glides to the
 * position. Under 360px it folds to ‹ PAGE 2 / 22 › with the ruler.
 */

const ARROW_LEFT = "M20 12 H5 M11 6 L5 12 L11 18";
const ARROW_RIGHT = "M4 12 H19 M13 6 L19 12 L13 18";

function Arrow({ d, className }: { d: string; className?: string }) {
  return (
    <svg aria-hidden width="14" height="14" viewBox="0 0 24 24" className={cn("transition-transform duration-[240ms] ease-[cubic-bezier(.34,1.6,.5,1)]", className)}>
      <path d={d} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
    </svg>
  );
}

const keyClass = cn(
  `notchset-focus`,
  "group/key flex size-10 flex-none cursor-pointer items-center justify-center border border-solid border-[var(--notchset-control-edge,var(--input))] bg-background p-0 text-foreground transition-[background-color,border-color,opacity] duration-[120ms,120ms,160ms] hover:border-foreground hover:bg-accent aria-disabled:cursor-not-allowed aria-disabled:opacity-[.38] aria-disabled:hover:border-[var(--notchset-control-edge,var(--input))] aria-disabled:hover:bg-background pointer-coarse:size-11",
);
const cellClass = cn(
  `notchset-focus`,
  "relative flex size-[38px] items-center justify-center font-mono text-[11px] font-medium tracking-[0.02em] tabular-nums text-foreground no-underline transition-colors duration-[120ms] [--notchset-focus-inset:-3px] hover:bg-accent data-[active=true]:bg-primary data-[active=true]:text-primary-foreground pointer-coarse:size-11",
);

// ---------------------------------------------------------------------------------------------
// shadcn's parts
// ---------------------------------------------------------------------------------------------

type InstrumentProps = {
  /** Pages in all. Passing it makes Pagination the instrument (children are ignored). */
  total: number;
  page?: number;
  defaultPage?: number;
  onPageChange?: (page: number) => void;
  /** Rows per page and rows in all, for the range header ("RUNS 11–20 OF 214"). */
  perPage?: number;
  count?: number;
  /** What the rows are ("RUNS"). */
  itemLabel?: string;
  /** Pages on each side of the current one in the window. Default 1 (seven slots). */
  siblings?: number;
  /** The position ruler. Default true. */
  ruler?: boolean;
  labels?: Partial<{ previous: string; next: string; page: string; of: string; more: string }>;
};

function Pagination(props: React.ComponentProps<"nav"> & Partial<InstrumentProps>) {
  if (props.total !== undefined) return <PaginationInstrument {...(props as React.ComponentProps<"nav"> & InstrumentProps)} />;
  const { className, ...rest } = props;
  return <nav {...NOTCHSET_ROOT} role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...rest} />;
}

function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
  return <ul data-slot="pagination-content" className={cn("m-0 flex list-none flex-row items-center gap-1.5 p-0", className)} {...props} />;
}

function PaginationItem(props: React.ComponentProps<"li">) {
  return <li data-slot="pagination-item" {...props} />;
}

type PaginationSize = "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg";
type PaginationLinkProps = { isActive?: boolean; size?: PaginationSize } & React.ComponentProps<"a">;

/** shadcn's Button sizes, as cells (an icon size is a square). */
const SIZE: Record<PaginationSize, string> = {
  default: "h-[38px] min-w-[38px] px-2.5",
  xs: "h-6 min-w-6 px-1.5 text-[9.5px]",
  sm: "h-8 min-w-8 px-2 text-[10px]",
  lg: "h-10 min-w-10 px-3 text-[12px]",
  icon: "size-[38px]",
  "icon-xs": "size-6",
  "icon-sm": "size-8",
  "icon-lg": "size-10",
};

function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
  return (
    <a
      {...OWN_SOUND}
      aria-current={isActive ? "page" : undefined}
      data-slot="pagination-link"
      data-active={isActive}
      data-size={size}
      className={cn(cellClass, "!size-auto border border-solid border-[var(--notchset-rule,var(--border))]", SIZE[size], className)}
      {...props}
    />
  );
}

function PaginationPrevious({ className, text = "Previous", size = "icon", isActive, children, ...props }: PaginationLinkProps & { text?: string }) {
  return (
    <a {...OWN_SOUND} aria-label="Go to previous page" aria-current={isActive ? "page" : undefined} data-slot="pagination-previous" data-size={size} className={cn(keyClass, size !== "icon" && "w-auto gap-1.5 px-3 font-mono text-[11px] tracking-[0.07em] uppercase", className)} {...props}>
      <Arrow d={ARROW_LEFT} className="group-hover/key:-translate-x-[2px]" />
      {children ?? (size === "icon" ? <span className="sr-only">{text}</span> : <span>{text}</span>)}
    </a>
  );
}

function PaginationNext({ className, text = "Next", size = "icon", isActive, children, ...props }: PaginationLinkProps & { text?: string }) {
  return (
    <a {...OWN_SOUND} aria-label="Go to next page" aria-current={isActive ? "page" : undefined} data-slot="pagination-next" data-size={size} className={cn(keyClass, size !== "icon" && "w-auto gap-1.5 px-3 font-mono text-[11px] tracking-[0.07em] uppercase", className)} {...props}>
      {children ?? (size === "icon" ? <span className="sr-only">{text}</span> : <span>{text}</span>)}
      <Arrow d={ARROW_RIGHT} className="group-hover/key:translate-x-[2px]" />
    </a>
  );
}

function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-[38px] items-center justify-center font-mono text-[11px] text-muted-foreground", className)} {...props}>
      ···
      <span className="sr-only">More pages</span>
    </span>
  );
}

// ---------------------------------------------------------------------------------------------
// The instrument
// ---------------------------------------------------------------------------------------------

type Slot = { kind: "page"; page: number } | { kind: "gap"; jump: number };

/** The window: every page when it fits, else the first, the last, the current with its siblings, and gaps. */
export function pageWindow(page: number, total: number, siblings = 1): Slot[] {
  const slots = 2 * siblings + 5;
  if (total <= slots) return Array.from({ length: total }, (_, i) => ({ kind: "page", page: i + 1 }));
  const edge = siblings + 3;
  const pages = (from: number, to: number): Slot[] => Array.from({ length: to - from + 1 }, (_, i) => ({ kind: "page", page: from + i }));
  if (page <= edge) return [...pages(1, slots - 2), { kind: "gap", jump: 3 }, { kind: "page", page: total }];
  if (page >= total - edge + 1) return [{ kind: "page", page: 1 }, { kind: "gap", jump: -3 }, ...pages(total - (slots - 3), total)];
  return [{ kind: "page", page: 1 }, { kind: "gap", jump: -3 }, ...pages(page - siblings, page + siblings), { kind: "gap", jump: 3 }, { kind: "page", page: total }];
}

const pad = (n: number, total: number) => String(n).padStart(String(total).length < 2 ? 2 : String(total).length, "0");
const fmt = new Intl.NumberFormat("en-US");

function PaginationInstrument({
  total,
  page: pageProp,
  defaultPage = 1,
  onPageChange,
  perPage,
  count,
  itemLabel = "ITEMS",
  siblings = 1,
  ruler = true,
  labels,
  className,
  "aria-label": ariaLabel = "Pages",
  ...props
}: React.ComponentProps<"nav"> & InstrumentProps) {
  const L = { previous: "Previous page", next: "Next page", page: "PAGE", of: "OF", more: "More pages", ...labels };
  const last = Math.max(1, Math.floor(total));
  const [own, setOwn] = React.useState(defaultPage);
  const page = Math.min(last, Math.max(1, pageProp ?? own));
  const listRef = React.useRef<HTMLDivElement>(null);
  const go = (next: number, focus = false) => {
    const to = Math.min(last, Math.max(1, next));
    if (to !== page) {
      playCue(listRef.current, "tick");
      setOwn(to);
      onPageChange?.(to);
    }
    if (focus) requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>('[aria-current="page"]')?.focus());
  };
  const slots = pageWindow(page, last, siblings);
  const at = slots.findIndex((s) => s.kind === "page" && s.page === page);
  const range =
    perPage && count !== undefined
      ? `${itemLabel} ${fmt.format(Math.min(count, (page - 1) * perPage + 1))}–${fmt.format(Math.min(count, page * perPage))} ${L.of} ${fmt.format(count)}`
      : `${L.page} ${pad(page, last)}`;
  const onKeyDown = (e: React.KeyboardEvent) => {
    const to = e.key === "ArrowLeft" ? page - 1 : e.key === "ArrowRight" ? page + 1 : e.key === "Home" ? 1 : e.key === "End" ? last : 0;
    if (!to) return;
    e.preventDefault();
    go(to, true);
  };
  const progress = last > 1 ? (page - 1) / (last - 1) : 0;
  const ticked = (p: number) => p === 1 || p === last || p % 5 === 0;

  return (
    <nav {...NOTCHSET_ROOT} aria-label={ariaLabel} data-slot="pagination" className={cn("@container flex w-[378px] max-w-full flex-col gap-3.5", className)} {...props}>
      <div className="flex items-baseline justify-between gap-3 whitespace-nowrap">
        <span aria-live="polite" className="font-mono text-[10px] font-medium tracking-[0.1em] uppercase tabular-nums">
          <FlipText>{range}</FlipText>
        </span>
        <span className="font-mono text-[8.5px] tracking-[0.06em] text-muted-foreground tabular-nums">
          {L.page} {pad(page, last)} / {pad(last, last)}
        </span>
      </div>
      <div className="flex items-center gap-1.5">
        <button {...OWN_SOUND} type="button" data-slot="pagination-prev" aria-label={L.previous} aria-disabled={page <= 1 || undefined} onClick={() => go(page - 1)} className={keyClass}>
          <Arrow d={ARROW_LEFT} className="group-hover/key:-translate-x-[2px] group-aria-disabled/key:!translate-x-0" />
        </button>
        <div ref={listRef} data-slot="pagination-list" onKeyDown={onKeyDown} className="relative flex border border-solid border-foreground bg-background @max-[359px]:hidden">
          <span
            aria-hidden
            data-slot="pagination-block"
            className="absolute inset-y-0 left-0 w-[38px] bg-primary transition-transform duration-[var(--notchset-block)] ease-[var(--notchset-ease-block)] pointer-coarse:w-11"
            style={{ transform: `translateX(calc(${Math.max(0, at)} * 100%))` }}
          />
          {slots.map((s, i) =>
            s.kind === "page" ? (
              <button
                key={`p${i}`}
                {...OWN_SOUND}
                type="button"
                aria-label={`${L.page.toLowerCase()} ${s.page}`}
                aria-current={s.page === page ? "page" : undefined}
                tabIndex={s.page === page ? 0 : -1}
                onClick={() => go(s.page)}
                className={cn(cellClass, "border-0 bg-transparent p-0 hover:bg-transparent delay-[0ms] data-[on]:text-primary-foreground data-[on]:delay-[110ms]", i > 0 && "border-l border-solid border-[var(--notchset-rule,var(--border))]")}
                data-on={s.page === page || undefined}
              >
                <FlipText>{String(s.page)}</FlipText>
              </button>
            ) : (
              <button
                key={`g${i}`}
                {...OWN_SOUND}
                type="button"
                aria-hidden
                tabIndex={-1}
                onClick={() => go(page + s.jump)}
                className={cn(cellClass, "border-0 bg-transparent p-0 text-muted-foreground", i > 0 && "border-l border-solid border-[var(--notchset-rule,var(--border))]")}
              >
                ···
              </button>
            ),
          )}
        </div>
        <span className="hidden flex-1 justify-center font-mono text-[10px] font-medium tracking-[0.1em] tabular-nums @max-[359px]:flex">
          {L.page} {page} / {last}
        </span>
        <button {...OWN_SOUND} type="button" data-slot="pagination-next" aria-label={L.next} aria-disabled={page >= last || undefined} onClick={() => go(page + 1)} className={keyClass}>
          <Arrow d={ARROW_RIGHT} className="group-hover/key:translate-x-[2px] group-aria-disabled/key:!translate-x-0" />
        </button>
      </div>
      {ruler && (
        <div aria-hidden data-slot="pagination-ruler" className="relative mx-1 h-2.5">
          <span className="absolute inset-x-0 top-[4.5px] h-px bg-[var(--notchset-rule,var(--border))]" />
          {Array.from({ length: last }, (_, i) => i + 1).map((p) => (
            <span
              key={p}
              className={cn("absolute w-px transition-colors duration-[160ms]", p <= page ? "bg-foreground" : "bg-[var(--notchset-control-edge,var(--input))]", ticked(p) ? "top-[1px] h-2" : "top-[3px] h-1")}
              style={{ left: `${last > 1 ? ((p - 1) / (last - 1)) * 100 : 0}%` }}
            />
          ))}
          <span className="absolute top-[4.5px] left-0 h-px origin-left bg-foreground transition-transform duration-[420ms] ease-[var(--notchset-ease-unfold)]" style={{ width: "100%", transform: `scaleX(${progress})` }} />
          <span className="absolute top-[1.5px] left-0 transition-[left] duration-[420ms] ease-[var(--notchset-ease-unfold)]" style={{ left: `${progress * 100}%` }}>
            <Dot className="-ml-[3.5px] text-foreground" />
          </span>
        </div>
      )}
    </nav>
  );
}

export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious };
```

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