# Bulk Delete (Notchset): prompt.md (v1.0.0)

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

Bulk delete where the button grows into its own confirmation: select files and DELETE unrolls across the header saying exactly what will go, the rows fold away one after another with five seconds to undo, and a failure shakes the card and keeps the selection.

## 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/bulk-delete.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: `BulkDelete`, 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-signal`, `--notchset-sweep-to`, `--notchset-travel`). Never add Tailwind colour classes inside the component.

```tsx
import { BulkDelete } from "@/components/ui/notchset/bulk-delete";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `BulkDelete` | `bulk-delete` | The card: header, delete control and rows. |
| `Control` | `bulk-delete-control` | DELETE, then the question, the progress, the result and Undo. |

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/bulk-delete.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
- bulk delete, delete selected, confirm delete, file list, album, undo delete, inline confirmation, Notchset
- Deleting a selection in a file list, an album or a table, right where the button sits
- Deletes that should offer Undo in place

### Not when
- A single destructive action: use Button with confirm, or Confirm Button (hold)
- Consequences that need a paragraph or typing a name: use an Alert Dialog

## Mistakes
- Do the final delete when Undo expires, or make onDelete reversible
- Reject with a short reason: it's shown in the header

## Usage

```tsx
"use client";

import { BulkDelete } from "@/components/ui/notchset/bulk-delete";

type File = { id: string; name: string; owner: string; size: string; mb: number };

export function AlbumFiles({ files, trash, restore }: { files: File[]; trash: (ids: string[]) => Promise<void>; restore: (ids: string[]) => void }) {
  return (
    <BulkDelete
      title="Braies shoot"
      items={files.map((f) => ({ id: f.id, name: f.name, meta: f.owner.toUpperCase(), size: f.size, mb: f.mb }))}
      onDelete={trash}
      onUndo={restore}
    />
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `title / items` | `string / { id, name, meta, size, mb, thumb? }[]` |  | The list and its rows; thumb is an image URL, or "doc" / "sheet" for an icon tile. |
| `selected / defaultSelected / onSelectedChange` | `string[]` |  | Controlled or uncontrolled selection. |
| `onDelete` | `(ids) => Promise` |  | Deletes the ids. Reject with an Error to show its message and keep the selection. |
| `undoMs / onUndo` | `number / (ids) => void` |  | How long Undo stays (default 5000) and what it restores. |
| `empty` | `ReactNode` |  | Shown when every row is gone. |

Full docs: https://beautiful-ui.dev/components/bulk-delete

## 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 |
|---|---|
| ↑ ↓ | Move between rows |
| Space / Enter | Select a row |
| Delete / Backspace | Ask to delete the selection |
| Esc | Cancel the question |

## Performance

- Plain DOM; rows fold with grid-template-rows transitions.

## Responsive

- 480px wide by default; on narrow screens the question truncates before the buttons.

## Motion inventory

| Interaction | What moves |
|---|---|
| Ask | The delete control unrolls across the header (460ms) and the title fades |
| Delete | The scanner runs; rows fade and slide 16px, 70ms apart, then fold away (380ms) |
| Undo | Five dots count down a second each; the rows grow back, still selected |
| Fail | The card shakes ±5px (360ms); rows that weren't deleted get a signal bar |

## Accessibility contract (preserve when editing)
- Rows are options in a multi-select listbox; select-all is a tri-state checkbox
- Opening the confirmation moves focus to CANCEL; Esc cancels; the selection is locked while it asks
- The question, the progress, the result and a failure are announced in the header's status

## Install

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

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

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/bulk-delete.tsx

```tsx
"use client";

/**
 * Bulk Delete (Notchset) v1.0.0 · Beautiful UI
 * Docs: https://beautiful-ui.dev/components/bulk-delete
 * MIT licensed: free to use, change and share.
 */

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

/*
 * Notchset Bulk Delete: bulk delete where the button grows into its own confirmation. Select files in
 * the list; DELETE appears in the header. Press it and it unrolls across the header, saying exactly
 * what will go (DELETE 2 FILES · 5.1 MB?), with CANCEL (focused) and DELETE 2. Deleting runs the
 * scanner; the rows then fold away one after another and the header offers Undo while five dots count
 * down. A failure shakes the card, keeps the selection and marks the rows that weren't deleted.
 */

export type BulkDeleteItem = {
  id: string;
  name: string;
  /** "SOFIA RAMIREZ · SEP 21" */
  meta: string;
  /** "4.2 MB" */
  size: string;
  /** Size in MB, for the total in the question. */
  mb: number;
  /** An image URL, or "doc" / "sheet" for an icon tile. */
  thumb?: string;
};

type Phase = "idle" | "confirm" | "busy" | "fail" | "done";

const UNDO_DOTS = 5;
const DOC = "M4 1.5 H9.5 L12.5 4.5 V14.5 H4 Z M9.5 1.5 V4.5 H12.5 M6 8 H10.5 M6 10.5 H10.5";
const SHEET = "M3 2.5 H13 V13.5 H3 Z M3 6 H13 M3 9.5 H13 M7 6 V13.5";
const BIN = "M2.5 4 H13.5 M6 4 V2.5 H10 V4 M4 4 L4.8 13.5 H11.2 L12 4 M6.6 6.5 V11 M9.4 6.5 V11";
const files = (n: number) => `${n} FILE${n === 1 ? "" : "S"}`;

export type BulkDeleteProps = Omit<React.ComponentProps<"div">, "children"> & {
  title: string;
  items: readonly BulkDeleteItem[];
  selected?: readonly string[];
  defaultSelected?: readonly string[];
  onSelectedChange?: (ids: string[]) => void;
  /** Deletes the ids. Reject with an Error to show its message (CONNECTION TIMED OUT) and keep the selection. */
  onDelete?: (ids: string[]) => Promise<unknown> | void;
  /** How long Undo stays (ms). Default 5000. */
  undoMs?: number;
  onUndo?: (ids: string[]) => void;
  /** Shown when every item is gone. */
  empty?: React.ReactNode;
};

function BulkDelete({ title, items, selected: selectedProp, defaultSelected = [], onSelectedChange, onDelete, undoMs = 5000, onUndo, empty, className, ref, ...props }: BulkDeleteProps) {
  const [own, setOwn] = React.useState<string[]>([...defaultSelected]);
  const selected = selectedProp ? [...selectedProp] : own;
  const setSelected = (ids: string[]) => {
    if (!selectedProp) setOwn(ids);
    onSelectedChange?.(ids);
  };
  const [phase, setPhase] = React.useState<Phase>("idle");
  const [gone, setGone] = React.useState<string[]>([]);
  const [lastDeleted, setLastDeleted] = React.useState<string[]>([]);
  const [error, setError] = React.useState("");
  const [undoLeft, setUndoLeft] = React.useState(0);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const refs = useComposedRefs(ref, rootRef);
  const cancelRef = React.useRef<HTMLButtonElement>(null);
  const openRef = React.useRef<HTMLButtonElement>(null);
  const latest = useLatest({ onDelete, onUndo });
  const op = React.useRef(0);
  const mounted = React.useRef(true);
  React.useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);

  const live = items.filter((i) => !gone.includes(i.id));
  const chosen = live.filter((i) => selected.includes(i.id));
  const n = chosen.length;
  const mb = chosen.reduce((sum, i) => sum + i.mb, 0);
  const locked = phase === "confirm" || phase === "busy";
  const wide = phase !== "idle";

  // CANCEL takes focus when the confirmation opens (after the header has unrolled into place).
  const focusCancel = React.useRef(false);
  React.useEffect(() => {
    if (phase === "confirm" && focusCancel.current) {
      focusCancel.current = false;
      cancelRef.current?.focus();
    }
  }, [phase]);

  // Undo counts down one dot a second; at zero the deletion is final.
  React.useEffect(() => {
    if (phase !== "done") return;
    const deadline = performance.now() + undoMs;
    const iv = setInterval(() => {
      const left = Math.max(0, Math.ceil(((deadline - performance.now()) / undoMs) * UNDO_DOTS));
      setUndoLeft(left);
      if (left <= 0) {
        clearInterval(iv);
        setPhase("idle");
      }
    }, 200);
    return () => clearInterval(iv);
  }, [phase, undoMs]);

  const open = () => {
    if (!n || locked) return;
    playCue(rootRef.current, "signal");
    focusCancel.current = true;
    setPhase("confirm");
  };
  const cancel = () => {
    playCue(rootRef.current, "tick");
    op.current++;
    setPhase("idle");
    openRef.current?.focus();
  };
  const go = async () => {
    const id = ++op.current;
    const ids = chosen.map((i) => i.id);
    playCue(rootRef.current, "confirm");
    setPhase("busy");
    try {
      await latest.current.onDelete?.(ids);
    } catch (err) {
      if (!mounted.current || op.current !== id) return;
      playCue(rootRef.current, "signal");
      setError(err instanceof Error && err.message ? err.message.toUpperCase() : "");
      // The card shakes once (±5px, 360ms); not when motion is off.
      if (!motionOff(rootRef.current)) rootRef.current?.animate([{ transform: "translateX(0)" }, { transform: "translateX(-5px)" }, { transform: "translateX(5px)" }, { transform: "translateX(-3px)" }, { transform: "translateX(2px)" }, { transform: "translateX(0)" }], { duration: 360, easing: "cubic-bezier(.65,0,.35,1)" });
      setPhase("fail");
      return;
    }
    if (!mounted.current || op.current !== id) return;
    playCue(rootRef.current, "done");
    setGone((g) => [...g, ...ids]);
    setLastDeleted(ids);
    setSelected(selected.filter((s) => !ids.includes(s)));
    setUndoLeft(UNDO_DOTS);
    setPhase("done");
  };
  const undo = () => {
    playCue(rootRef.current, "done");
    setGone((g) => g.filter((x) => !lastDeleted.includes(x)));
    setSelected([...selected, ...lastDeleted]);
    latest.current.onUndo?.(lastDeleted);
    setPhase("idle");
  };

  const toggle = (id: string) => {
    if (locked) return;
    playCue(rootRef.current, "tick");
    setSelected(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
    if (phase === "fail" || phase === "done") setPhase("idle");
  };
  const allOn = live.length > 0 && live.every((i) => selected.includes(i.id));
  const toggleAll = () => {
    if (locked || !live.length) return;
    playCue(rootRef.current, "tick");
    setSelected(allOn ? [] : live.map((i) => i.id));
    if (phase === "fail") setPhase("idle");
  };

  const message =
    phase === "confirm" ? `DELETE ${files(n)} · ${mb.toFixed(1)} MB?` : phase === "busy" ? `DELETING ${files(n)}` : phase === "fail" ? ["COULDN’T DELETE", error].filter(Boolean).join(" · ") : phase === "done" ? `${files(lastDeleted.length)} DELETED` : "";
  let goneIndex = 0;

  return (
    <div
      {...NOTCHSET_ROOT}
      {...props}
      ref={refs}
      data-slot="bulk-delete"
      data-state={phase}
      onKeyDown={(e) => {
        props.onKeyDown?.(e);
        if (!e.defaultPrevented && e.key === "Escape" && phase === "confirm") {
          e.preventDefault();
          cancel();
        }
      }}
      className={cn("w-[480px] max-w-full border border-solid border-foreground bg-background text-foreground", className)}
    >
      {/* Header: select-all, the title and its count, and the delete control that grows across it. */}
      <div className="relative flex h-[72px] items-center gap-3.5 border-0 border-b border-solid border-foreground ps-4 pe-4">
        <button
          {...OWN_SOUND}
          type="button"
          role="checkbox"
          aria-checked={allOn ? true : n ? "mixed" : false}
          aria-label="Select all"
          disabled={locked || !live.length}
          onClick={toggleAll}
          className={cn(`notchset-focus`, "flex size-[18px] flex-none cursor-pointer items-center justify-center border border-solid p-0 transition-colors duration-[140ms]", n ? "border-foreground bg-foreground text-background" : "border-[var(--notchset-control-edge,var(--input))] bg-transparent")}
        >
          {allOn ? <Tick drawn /> : n ? <span aria-hidden className="h-[2px] w-2 bg-current" /> : null}
        </button>
        <div className={cn("flex min-w-0 flex-col gap-1 transition-opacity duration-200", wide ? "opacity-0" : "opacity-100")} aria-hidden={wide || undefined}>
          <span className="truncate text-[14px] font-medium">{title}</span>
          <span className="flex items-center gap-1 font-mono text-[11px] tracking-[0.06em] text-muted-foreground uppercase">
            {n ? (
              <>
                <RollingNumber value={n} height={14} className="text-foreground" /> OF {live.length} SELECTED · {mb.toFixed(1)} MB
              </>
            ) : (
              `${files(live.length)} · SELECT TO DELETE`
            )}
          </span>
        </div>

        <div
          data-slot="bulk-delete-control"
          className={cn(
            "absolute inset-y-[15px] end-4 flex items-center overflow-hidden border border-solid bg-background",
            phase === "done" ? "border-foreground" : "border-[var(--notchset-signal-text,var(--destructive))]",
            wide ? "w-[calc(100%-32px)] transition-[width] duration-[460ms] ease-[cubic-bezier(.3,1.2,.4,1)]" : "transition-[width,opacity] duration-[320ms,200ms] ease-[cubic-bezier(.7,0,.25,1),linear]",
            !wide && (n ? "w-28 opacity-100" : "pointer-events-none w-0 opacity-0"),
          )}
        >
          {phase === "idle" ? (
            <button
              {...OWN_SOUND}
              ref={openRef}
              type="button"
              tabIndex={n ? 0 : -1}
              aria-label={`Delete ${n} selected ${n === 1 ? "file" : "files"}`}
              onClick={open}
              className={cn(`notchset-focus`, "flex h-full w-full cursor-pointer items-center justify-center gap-2 border-0 bg-background font-mono text-[12px] font-medium tracking-[0.06em] whitespace-nowrap text-[color:var(--notchset-signal-text,var(--destructive))]")}
            >
              <Glyph d={BIN} />
              DELETE
            </button>
          ) : (
            <div className="flex h-full w-full items-center gap-2 ps-3 pe-1">
              <span role="status" className={cn("flex min-w-0 flex-1 items-center gap-2 truncate font-mono text-[12px] tracking-[0.04em] whitespace-nowrap", phase === "done" ? "text-foreground" : "text-[color:var(--notchset-signal-text,var(--destructive))]")}>
                {phase === "busy" && <Scanner height={12} />}
                <FlipText>{message}</FlipText>
              </span>
              {(phase === "confirm" || phase === "fail") && (
                <>
                  <button {...OWN_SOUND} ref={cancelRef} type="button" onClick={cancel} className={cn(`notchset-focus`, "flex h-8 flex-none cursor-pointer items-center border-0 bg-transparent px-2.5 font-mono text-[12px] font-medium tracking-[0.06em] text-foreground hover:bg-accent")}>
                    CANCEL
                  </button>
                  <button {...OWN_SOUND} type="button" onClick={go} className={cn(`notchset-focus`, "flex h-8 flex-none cursor-pointer items-center border-0 bg-[var(--notchset-signal-text,var(--destructive))] px-3 font-mono text-[12px] font-medium tracking-[0.06em] text-primary-foreground")}>
                    {phase === "fail" ? "RETRY" : `DELETE ${n}`}
                  </button>
                </>
              )}
              {phase === "done" && (
                <button {...OWN_SOUND} type="button" onClick={undo} className={cn(`notchset-focus`, "flex h-8 flex-none cursor-pointer items-center gap-2.5 border-0 bg-transparent px-2.5 font-mono text-[12px] font-medium tracking-[0.06em] text-foreground hover:bg-accent")}>
                  <span aria-hidden className="flex gap-[5px]">
                    {Array.from({ length: UNDO_DOTS }, (_, i) => (
                      <span key={i} className={cn("size-[5px] rounded-full transition-colors duration-200", i < undoLeft ? "bg-foreground" : "bg-[var(--notchset-rule,var(--border))]")} />
                    ))}
                  </span>
                  UNDO
                </button>
              )}
            </div>
          )}
        </div>
      </div>

      {/* The rows. */}
      <div
        role="listbox"
        aria-label={title}
        aria-multiselectable
        onKeyDown={(e) => {
          if (composing(e)) return;
          const opts = [...e.currentTarget.querySelectorAll<HTMLElement>("[role=option]:not([aria-hidden=true])")];
          const at = opts.indexOf(document.activeElement as HTMLElement);
          if (e.key === "ArrowDown" || e.key === "ArrowUp") {
            e.preventDefault();
            opts[Math.min(opts.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)))]?.focus();
          } else if ((e.key === "Delete" || e.key === "Backspace") && n) {
            e.preventDefault();
            open();
          }
        }}
      >
        {items.map((item) => {
          const isGone = gone.includes(item.id);
          const isOn = selected.includes(item.id) && !isGone;
          const failed = phase === "fail" && isOn;
          const k = isGone ? goneIndex++ : 0;
          return (
            <div
              key={item.id}
              aria-hidden={isGone || undefined}
              className="grid border-0 border-b border-solid border-[var(--notchset-rule,var(--border))] last:border-b-0"
              style={{ gridTemplateRows: isGone ? "0fr" : "1fr", transition: isGone ? `grid-template-rows 380ms cubic-bezier(.7,0,.25,1) ${180 + k * 70}ms` : "grid-template-rows 420ms cubic-bezier(.3,1.2,.4,1)" }}
            >
              <div className="min-h-0 overflow-hidden">
                <div
                  role="option"
                  aria-selected={isOn}
                  tabIndex={isGone ? -1 : 0}
                  onClick={() => toggle(item.id)}
                  onKeyDown={(e) => {
                    if (e.key === " " || e.key === "Enter") {
                      e.preventDefault();
                      toggle(item.id);
                    }
                  }}
                  className={cn(
                    `notchset-focus`, "relative flex h-16 items-center gap-3.5 ps-4 pe-4 outline-none [--notchset-focus-inset:-3px]",
                    locked ? "cursor-default" : "cursor-pointer",
                    isOn ? "bg-[var(--notchset-sheet,var(--muted))]" : "bg-background",
                  )}
                  style={{ opacity: isGone ? 0 : 1, transform: isGone ? "translateX(-16px)" : "none", transition: isGone ? `opacity 220ms linear ${k * 70}ms, transform 300ms cubic-bezier(.7,0,.25,1) ${k * 70}ms` : "opacity 260ms linear 120ms, transform 420ms cubic-bezier(.34,1.3,.5,1) 120ms, background-color 160ms linear" }}
                >
                  <span aria-hidden className={cn("absolute inset-y-0 start-0 w-[2px] origin-center transition-transform duration-[220ms]", failed ? "bg-[var(--notchset-signal-text,var(--destructive))]" : "bg-foreground", isOn ? "scale-y-100" : "scale-y-0")} />
                  <span aria-hidden className={cn("flex size-[18px] flex-none items-center justify-center border border-solid transition-colors duration-[140ms]", isOn ? "border-foreground bg-foreground text-background" : "border-[var(--notchset-control-edge,var(--input))]")}>
                    <Tick drawn={isOn} />
                  </span>
                  <Thumb thumb={item.thumb} />
                  <span className="flex min-w-0 flex-1 flex-col gap-1">
                    <span className="truncate text-[14px] font-medium">{item.name}</span>
                    <span className={cn("truncate font-mono text-[11px] tracking-[0.06em] uppercase", failed ? "text-[color:var(--notchset-signal-text,var(--destructive))]" : "text-muted-foreground")}>{failed ? "NOT DELETED · STILL SELECTED" : item.meta}</span>
                  </span>
                  <span className="flex-none font-mono text-[12px] tracking-[0.04em] text-muted-foreground">{item.size}</span>
                </div>
              </div>
            </div>
          );
        })}
      </div>
      {live.length === 0 && phase !== "done" && empty && <div className="border-0 border-t border-solid border-foreground p-6">{empty}</div>}
    </div>
  );
}

function Glyph({ d }: { d: string }) {
  return (
    <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" className="flex-none">
      <path d={d} fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="square" />
    </svg>
  );
}

/** The check: it draws in (220ms trace) when it turns on. */
function Tick({ drawn }: { drawn: boolean }) {
  return (
    <svg aria-hidden width="12" height="12" viewBox="0 0 12 12">
      <path d="M2 6.5 L5 9.5 L10 3" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="square" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: drawn ? 0 : 1, transition: "stroke-dashoffset 220ms cubic-bezier(.7,0,.25,1)" }} />
    </svg>
  );
}

function Thumb({ thumb }: { thumb?: string }) {
  if (!thumb || thumb === "doc" || thumb === "sheet")
    return (
      <span aria-hidden className="flex size-10 flex-none items-center justify-center border border-solid border-[var(--notchset-rule,var(--border))]">
        <Glyph d={thumb === "sheet" ? SHEET : DOC} />
      </span>
    );
  return (
    <img src={thumb} alt="" width={40} height={40} className="size-10 flex-none border border-solid border-[var(--notchset-rule,var(--border))] object-cover" />
  );
}

export { BulkDelete };
```

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