# User Menu (Notchset): prompt.md (v1.0.0)

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

The account key at the end of an app bar: workspace and avatar on a two-part trigger, and a panel that unrolls under a leader with who's signed in, a workspace switch, a month of token use, the CLI login line, links under one gliding highlight, a theme switch and sign out.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@base-ui/react@^1` (Base UI build); `radix-ui@^1` (Radix build).
- Files: `components/ui/notchset/user-menu.tsx`; shared code: `lib/beautiful-ui/notchset/user-menu.tsx`, `lib/beautiful-ui/notchset/instrument.tsx`, `lib/beautiful-ui/notchset/root.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`.
- Builds: separate Base UI and Radix files. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `UserMenu`, 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 { UserMenu, type UserMenuTheme, type UserMenuWorkspace } from "@/components/ui/notchset/user-menu";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `trigger` | `user-menu-trigger` | The two-part key. |
| `panel` | `user-menu-content` | The menu. |

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/user-menu.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
- user menu, account menu, avatar menu, workspace switcher, profile dropdown, sign out, Notchset
- The account corner of a product: identity, workspace, usage and sign out in one place

### Not when
- A short list of actions: use Dropdown Menu

## Mistakes
- The theme choice only reports: apply it to <html> (the dark class) and save it, or the page never changes

## Usage

```tsx
"use client";

import * as React from "react";
import { UserMenu, type UserMenuTheme, type UserMenuWorkspace } from "@/components/ui/notchset/user-menu";

const workspaces: UserMenuWorkspace[] = [
  { id: "relay", name: "Relay", env: "PROD", slug: "relay", agents: "12 AGENTS" },
  { id: "relay-staging", name: "Relay", env: "STAGING", slug: "relay-staging" },
];

// The theme lives where ThemeSwitch and NotchsetThemeScript keep it: the dark class on <html> and
// localStorage "theme" (no entry means follow the system).
const KEY = "theme";
const listeners = new Set<() => void>();
function savedTheme(): UserMenuTheme {
  try {
    const t = localStorage.getItem(KEY);
    return t === "dark" || t === "light" ? t : "system";
  } catch {
    return "system";
  }
}
function applyTheme(t: UserMenuTheme) {
  const dark = t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
  document.documentElement.classList.toggle("dark", dark);
  document.documentElement.style.colorScheme = dark ? "dark" : "light";
  try {
    if (t === "system") localStorage.removeItem(KEY);
    else localStorage.setItem(KEY, t);
  } catch {
    // Blocked storage: the page still changes for this visit.
  }
  listeners.forEach((l) => l());
}
function subscribe(l: () => void) {
  listeners.add(l);
  window.addEventListener("storage", l);
  return () => {
    listeners.delete(l);
    window.removeEventListener("storage", l);
  };
}

export function AccountMenu({ openProfile, signOut }: { openProfile: () => void; signOut: () => Promise<void> }) {
  const [workspace, setWorkspace] = React.useState("relay");
  const theme = React.useSyncExternalStore(subscribe, savedTheme, () => "system" as const);
  return (
    <UserMenu
      user={{ name: "Maya Kerr", initials: "MK", role: "OWNER · 2FA ON" }}
      workspaces={workspaces}
      workspace={workspace}
      onWorkspaceChange={setWorkspace}
      links={[{ id: "profile", label: "PROFILE", hint: "⌘P", onSelect: openProfile }]}
      theme={theme}
      onThemeChange={applyTheme}
      onSignOut={signOut}
    />
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `user / session` | `{ name, initials, role? } / { device, expires }` |  | The identity cells. |
| `workspaces / workspace / onWorkspaceChange` | `{ id, name, env, slug, agents? }[] / string / fn` |  | The switch; also shown on the trigger. |
| `usage` | `{ daily, used, cap, days?, marker?, title?, axis? }` |  | The lollipop plot of daily use. |
| `cliCommand` | `(slug) => string` |  | The CLI line with Copy. |
| `links` | `{ id, label, hint?, onSelect? }[]` |  | The link rows. |
| `theme / onThemeChange` | `"light" \| "system" \| "dark" / fn` |  | The theme choice; the menu only reports it. Apply it the way Theme Switch does: the dark class on <html> and localStorage "theme" (see the example). |
| `onSignOut` | `() => Promise` |  | Sign out shows its scanner until this settles, then closes. |

Full docs: https://notchset.dev/components/user-menu

## 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 |
|---|---|
| ↑ / ↓ | Move through the panel |
| Enter / Space | Activate |
| Esc | Close |

## Performance

- The panel mounts only while open; the lollipop stems are CSS animations staggered by delay.
- The links' highlight is one element moved by top.

## Responsive

- 320px, never wider than the screen less 20px; every row is 44px on touch screens.

## Motion inventory

| Interaction | What moves |
|---|---|
| Open | The leader drops 9px (120ms), the panel unrolls (420ms after 100ms), sections settle in 45ms apart, stems grow 14ms apart |
| Trigger | The chevron turns (320ms) and a ring forms around the avatar |
| Links | One highlight glides between rows (170ms) |

## Accessibility contract (preserve when editing)
- A menu button: aria-haspopup and aria-expanded on the trigger
- Workspace and theme are radio items; Copy and Sign out keep the menu open
- Esc and outside clicks close it and focus returns to the trigger

## Install

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

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

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/user-menu.tsx

```tsx
"use client";

/**
 * User Menu (Notchset) v1.0.0 · Notchset
 * Docs: https://notchset.dev/components/user-menu
 * MIT licensed: free to use, change and share.
 */

import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
import { NOTCHSET_ROOT, OWN_SOUND, useNotchsetPortal } from "@/lib/beautiful-ui/notchset/instrument";
import {
  USER_MENU_LABELS,
  UserMenuLeader,
  UserMenuPanel,
  UserMenuTriggerFace,
  userMenuPopupClass,
  userMenuTriggerClass,
  type UserMenuPanelProps,
  type UserMenuParts,
} from "@/lib/beautiful-ui/notchset/user-menu";

/*
 * Notchset User Menu (Base UI build, Menu). The account key at the end of an app bar: workspace and
 * avatar on the trigger; identity, workspace switch, token use, the CLI line, links, theme and sign
 * out in the panel. Arrow keys move through every control in it; Esc and outside clicks close it and
 * focus returns to the trigger.
 */

const parts: UserMenuParts = {
  Item: ({ keepOpen, onAction, ...props }) => <MenuPrimitive.Item {...OWN_SOUND} closeOnClick={!keepOpen} onClick={(e) => onAction(e.currentTarget)} {...props} />,
  RadioGroup: (props) => <MenuPrimitive.RadioGroup {...props} />,
  RadioItem: (props) => <MenuPrimitive.RadioItem {...OWN_SOUND} closeOnClick={false} {...props} />,
};

function UserMenu({
  className,
  container,
  open: openProp,
  onOpenChange,
  ...props
}: UserMenuPanelProps & {
  className?: string;
  container?: MenuPrimitive.Portal.Props["container"];
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}) {
  const [own, setOwn] = React.useState(false);
  const open = openProp ?? own;
  const change = (next: boolean) => {
    setOwn(next);
    onOpenChange?.(next);
  };
  const scoped = useNotchsetPortal();
  const ws = props.workspaces?.find((w) => w.id === props.workspace) ?? props.workspaces?.[0];
  return (
    <MenuPrimitive.Root open={open} onOpenChange={change}>
      <MenuPrimitive.Trigger
        {...NOTCHSET_ROOT}
        data-slot="user-menu-trigger"
        aria-label={props.labels?.trigger ?? USER_MENU_LABELS.trigger}
        className={cn(`notchset-focus`, userMenuTriggerClass, className)}
      >
        <UserMenuTriggerFace user={props.user} workspace={ws} />
      </MenuPrimitive.Trigger>
      <MenuPrimitive.Portal container={container ?? scoped}>
        <MenuPrimitive.Positioner className="z-50 outline-none" side="bottom" align="end" sideOffset={9} collisionPadding={10}>
          <MenuPrimitive.Popup {...NOTCHSET_ROOT} data-slot="user-menu-content" className={userMenuPopupClass}>
            <UserMenuLeader />
            <UserMenuPanel {...props} parts={parts} close={() => change(false)} />
          </MenuPrimitive.Popup>
        </MenuPrimitive.Positioner>
      </MenuPrimitive.Portal>
    </MenuPrimitive.Root>
  );
}

export { UserMenu };
export type { UserMenuLink, UserMenuTheme, UserMenuUsage, UserMenuUser, UserMenuWorkspace } from "@/lib/beautiful-ui/notchset/user-menu";
```

### lib/beautiful-ui/notchset/user-menu.tsx

```tsx
"use client";

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

/*
 * Notchset User Menu, shared by both builds. The two-part trigger (workspace over env,
 * then the avatar and a chevron that turns), and the panel's sections: who and which session, the
 * workspace switch, a month of daily token use as a lollipop plot with its migration marker, the CLI
 * login line with Copy, links under one gliding highlight, and the theme switch beside Sign out.
 * Each build adapts its primitive's Item, RadioGroup and RadioItem and passes them in.
 */

export type UserMenuUser = { name: string; initials: string; role?: string };
export type UserMenuSession = { device: string; expires: string };
export type UserMenuWorkspace = { id: string; name: string; env: string; slug: string; agents?: string };
export type UserMenuUsage = {
  /** Daily values so far this month (the last one is today). */
  daily: readonly number[];
  /** Days in the month (31). */
  days?: number;
  used: string;
  cap: string;
  /** A dashed signal marker before a day (1-based): the day things changed. */
  marker?: { day: number; label: string };
  title?: string;
  /** Under the plot: start, middle and end captions. */
  axis?: readonly [string, string, string];
};
export type UserMenuLink = { id: string; label: string; hint?: string; onSelect?: () => void };
export type UserMenuTheme = "light" | "system" | "dark";

export type UserMenuLabels = {
  trigger: string;
  signedInAs: string;
  session: string;
  workspace: string;
  copy: string;
  copied: string;
  copyAria: string;
  theme: string;
  themes: Record<UserMenuTheme, string>;
  signOut: string;
  signingOut: string;
  signOutFailed: string;
  signOutKbd?: string;
};

export const USER_MENU_LABELS: UserMenuLabels = {
  trigger: "Account and workspace",
  signedInAs: "SIGNED IN AS",
  session: "SESSION",
  workspace: "WORKSPACE",
  copy: "COPY",
  copied: "✓ COPIED",
  copyAria: "Copy CLI command",
  theme: "Theme",
  themes: { light: "Light", system: "System", dark: "Dark" },
  signOut: "SIGN OUT",
  signingOut: "SIGNING OUT",
  signOutFailed: "COULDN’T SIGN OUT · RETRY",
  signOutKbd: "⇧⌘Q",
};

// ---------------------------------------------------------------------------------------------
// Trigger
// ---------------------------------------------------------------------------------------------


export const userMenuTriggerClass = cn(
  "group/um flex h-10 cursor-pointer items-center border border-solid border-[var(--notchset-rule,var(--border))] bg-background p-0 pe-1 text-foreground transition-[border-color,background-color] duration-(--notchset-fade) ease-(--notchset-ease-fade) hover:border-foreground pointer-coarse:h-11",
  "data-[popup-open]:border-foreground data-[popup-open]:bg-accent data-[state=open]:border-foreground data-[state=open]:bg-accent",
);

export function UserMenuTriggerFace({ user, workspace }: { user: UserMenuUser; workspace?: UserMenuWorkspace }) {
  return (
    <>
      {workspace && (
        <span
          className={cn(
            "flex h-full flex-col items-start justify-center gap-0.5 border-e border-solid border-[var(--notchset-rule,var(--border))] ps-3 pe-2.5 transition-[border-color] duration-(--notchset-fade) ease-(--notchset-ease-fade) group-hover/um:border-foreground",
            "group-data-[popup-open]/um:border-foreground group-data-[state=open]/um:border-foreground",
          )}
        >
          <span className="font-mono text-[10px] tracking-[0.12em] text-muted-foreground">{workspace.env}</span>
          <span className="font-mono text-[11px] font-medium tracking-[0.08em] whitespace-nowrap">{workspace.name}</span>
        </span>
      )}
      <span className="flex items-center gap-2 ps-2 pe-1.5">
        <span className="relative flex size-[26px] items-center justify-center rounded-full bg-foreground font-mono text-[10px] font-medium text-background">
          {user.initials}
          <span
            aria-hidden
            className={cn(
              "absolute -inset-1 scale-[.8] rounded-full border border-solid border-foreground opacity-0 transition-[opacity,scale] duration-[140ms,260ms] ease-[linear,cubic-bezier(.34,1.3,.5,1)]",
              "group-data-[popup-open]/um:scale-100 group-data-[popup-open]/um:opacity-100 group-data-[state=open]/um:scale-100 group-data-[state=open]/um:opacity-100",
            )}
          />
        </span>
        <svg aria-hidden width="10" height="10" viewBox="0 0 12 12" className={cn("transition-[rotate] duration-[320ms] ease-[cubic-bezier(.34,1.5,.5,1)]", "group-data-[popup-open]/um:rotate-180 group-data-[state=open]/um:rotate-180")}>
          <path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" />
        </svg>
      </span>
    </>
  );
}

// ---------------------------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------------------------

/** The popup: 320px, ink frame, unrolling by grid rows after its leader drops (100ms). */
export const userMenuPopupClass = cn(
  "group/menu relative z-50 grid w-[320px] max-w-[calc(100vw-20px)] grid-rows-[1fr] border border-solid border-foreground bg-background text-foreground outline-none",
  "transition-[grid-template-rows] duration-(--notchset-unfold) ease-(--notchset-ease-unfold) delay-(--notchset-unfold-delay)",
  "data-[starting-style]:grid-rows-[0fr] data-[ending-style]:grid-rows-[0fr] data-[ending-style]:delay-0 data-[ending-style]:duration-(--notchset-fold) data-[ending-style]:ease-(--notchset-ease-fold)",
  "motion-safe:data-[state=open]:animate-[notchset-unfold_var(--notchset-unfold)_var(--notchset-ease-unfold)_var(--notchset-unfold-delay)_both] motion-safe:data-[state=closed]:animate-[notchset-fold_var(--notchset-fold)_var(--notchset-ease-fold)_both]",
);

/** The leader dropping from the avatar and the node on the panel's top edge (centred on the avatar, 41px from the panel's end). */
export function UserMenuLeader() {
  return (
    <>
      <span aria-hidden className="pointer-events-none absolute end-[40.5px] -top-[10px] h-[9px] w-px origin-top bg-foreground motion-safe:animate-[notchset-leader-y_120ms_var(--notchset-ease-travel)_both]" />
      <span aria-hidden className="pointer-events-none absolute end-[37.5px] -top-[3.5px] size-[7px] rounded-full bg-foreground" />
    </>
  );
}

/** A section: fades (180ms) and settles from -5px (380ms), 45ms after the one before. */
function Section({ k, className, children }: { k: number; className?: string; children: React.ReactNode }) {
  return (
    <div
      className={cn(
        "border-b border-solid border-[var(--notchset-rule,var(--border))] motion-safe:animate-[notchset-fade-in_180ms_linear_calc(150ms+var(--k)*45ms)_both,notchset-drop_380ms_var(--notchset-ease-key-up)_calc(150ms+var(--k)*45ms)_both]",
        className,
      )}
      style={{ "--k": k } as React.CSSProperties}
    >
      {children}
    </div>
  );
}

const micro = "font-mono text-[10px] tracking-[0.12em] text-muted-foreground";

/** The month's daily tokens: stems grow from the baseline (14ms apart); a dashed signal line marks the change. */
function UsagePlot({ usage }: { usage: UserMenuUsage }) {
  const days = usage.days ?? 31;
  const pitch = 292 / days;
  const max = Math.max(1e-9, ...usage.daily);
  const today = usage.daily.length - 1;
  const mark = usage.marker ? pitch * (usage.marker.day - 1) : undefined;
  return (
    <svg aria-hidden width="100%" height="44" viewBox="0 0 292 44" preserveAspectRatio="none" className="block overflow-visible">
      <line x1="0" y1="38.5" x2="292" y2="38.5" strokeWidth="1" className="stroke-[var(--notchset-rule,var(--border))]" />
      {mark !== undefined && (
        <>
          <line x1={mark} y1="2" x2={mark} y2="38" strokeWidth="1" strokeDasharray="2 2" className="stroke-[var(--notchset-signal-text,var(--destructive))]" />
          <text x={mark + 4} y="7" className="fill-[var(--notchset-signal-text,var(--destructive))] font-mono text-[10px] font-medium tracking-[0.08em]">
            {usage.marker!.label}
          </text>
        </>
      )}
      {Array.from({ length: days }, (_, i) => {
        const x = pitch * i + pitch / 2;
        const v = usage.daily[i];
        if (v === undefined) return <circle key={i} cx={x} cy="38" r="1" className="fill-[var(--notchset-rule,var(--border))]" />;
        const y = 38 - (v / max) * 31.7;
        const after = usage.marker ? i >= usage.marker.day - 1 : true;
        const delay = `${260 + i * 14}ms`;
        return (
          <g key={i}>
            <line
              x1={x}
              y1="38"
              x2={x}
              y2={y}
              strokeWidth="1"
              className={cn("origin-bottom [transform-box:fill-box] motion-safe:animate-[notchset-leader-y_520ms_cubic-bezier(.34,1.3,.5,1)_both]", after ? "stroke-foreground" : "stroke-[var(--notchset-control-edge,var(--input))]")}
              style={{ animationDelay: delay }}
            />
            <circle
              cx={x}
              cy={y}
              r={i === today ? 2.4 : 1.6}
              strokeWidth="1"
              className={cn("motion-safe:animate-[notchset-head-rise_520ms_cubic-bezier(.34,1.3,.5,1)_both]", i === today ? "fill-background stroke-foreground" : "fill-foreground stroke-none")}
              style={{ animationDelay: delay, "--notchset-rise-from": `${38 - y}px` } as React.CSSProperties}
            />
          </g>
        );
      })}
    </svg>
  );
}

const THEME_GLYPH: Record<UserMenuTheme, string> = {
  light: "",
  system: "M6 1.5 A4.5 4.5 0 0 0 6 10.5 Z",
  dark: "M6 1.5 A4.5 4.5 0 0 1 6 10.5 A4.5 4.5 0 0 1 6 1.5 Z",
};
const THEMES: UserMenuTheme[] = ["light", "system", "dark"];

/** The primitive's parts, adapted by each build. `keepOpen` items don't close the menu. */
export type UserMenuParts = {
  Item: React.ComponentType<{ className?: string; keepOpen?: boolean; onAction: (el: HTMLElement) => void; onFocus?: (e: React.FocusEvent<HTMLElement>) => void; onBlur?: () => void; "aria-label"?: string; children: React.ReactNode }>;
  RadioGroup: React.ComponentType<{ value: string; onValueChange: (v: string) => void; className?: string; "aria-label"?: string; children: React.ReactNode }>;
  RadioItem: React.ComponentType<{ value: string; className?: string; "aria-label"?: string; children: React.ReactNode }>;
};

export type UserMenuPanelProps = {
  user: UserMenuUser;
  session?: UserMenuSession;
  workspaces?: readonly UserMenuWorkspace[];
  workspace?: string;
  onWorkspaceChange?: (id: string) => void;
  usage?: UserMenuUsage;
  cliCommand?: (slug: string) => string;
  links?: readonly UserMenuLink[];
  theme?: UserMenuTheme;
  onThemeChange?: (theme: UserMenuTheme) => void;
  onSignOut?: () => Promise<unknown> | void;
  labels?: Partial<UserMenuLabels>;
};

export function UserMenuPanel({ parts: P, close, ...props }: UserMenuPanelProps & { parts: UserMenuParts; close: () => void }) {
  const { user, session, workspaces = [], workspace, onWorkspaceChange, usage, cliCommand, links = [], theme, onThemeChange, onSignOut } = props;
  const L = { ...USER_MENU_LABELS, ...props.labels };
  const ws = workspaces.find((w) => w.id === workspace) ?? workspaces[0];
  // The highlighted link's own box, measured: rows are 32px, or 44px on touch screens.
  const [highlight, setHighlight] = React.useState<{ i: number; top: number; height: number } | null>(null);
  const [copied, setCopied] = React.useState(false);
  const [signingOut, setSigningOut] = React.useState(false);
  const [signOutFailed, setSignOutFailed] = React.useState(false);
  React.useEffect(() => {
    if (!copied) return;
    const t = setTimeout(() => setCopied(false), 1400);
    return () => clearTimeout(t);
  }, [copied]);

  const cli = ws && cliCommand ? cliCommand(ws.slug) : undefined;
  let k = 0;
  return (
    <div className="min-h-0 overflow-hidden">
      <Section k={k++} className="grid grid-cols-2">
        <div className="flex flex-col gap-[3px] border-e border-solid border-[var(--notchset-rule,var(--border))] px-3.5 py-3">
          <span className={micro}>{L.signedInAs}</span>
          <span className="font-sans text-[13.5px] leading-[1.2] font-medium">{user.name}</span>
          {user.role && <span className="font-mono text-[10px] text-muted-foreground">{user.role}</span>}
        </div>
        {session && (
          <div className="flex flex-col gap-[3px] px-3.5 py-3">
            <span className={micro}>{L.session}</span>
            <span className="font-sans text-[13.5px] leading-[1.2] font-medium">{session.device}</span>
            <span className="font-mono text-[10px] text-muted-foreground">{session.expires}</span>
          </div>
        )}
      </Section>

      {workspaces.length > 0 && (
        <Section k={k++} className="px-3.5 pt-2.5 pb-2">
          <span className={micro}>{L.workspace}</span>
          <P.RadioGroup value={ws?.id ?? ""} onValueChange={(v) => onWorkspaceChange?.(v)} aria-label={L.workspace}>
            {workspaces.map((w) => (
              <P.RadioItem
                key={w.id}
                value={w.id}
                className={cn(
                  `notchset-focus`,
                  "group/ws [--notchset-focus-inset:-2px] flex h-8 w-full cursor-pointer items-center gap-2.5 font-mono text-[11px] font-medium tracking-[0.08em] text-muted-foreground outline-none data-[checked]:text-foreground data-[state=checked]:text-foreground pointer-coarse:h-11",
                )}
              >
                <svg aria-hidden width="13" height="13" viewBox="0 0 13 13" className="flex-none overflow-visible">
                  <circle
                    cx="6.5"
                    cy="6.5"
                    r="4.5"
                    strokeWidth="1"
                    className="fill-background stroke-[var(--notchset-control-edge,var(--input))] group-data-[checked]/ws:stroke-foreground group-data-[state=checked]/ws:stroke-foreground"
                  />
                  <circle
                    cx="6.5"
                    cy="6.5"
                    className="fill-foreground [r:0px] transition-[r] duration-[260ms] ease-[cubic-bezier(.34,1.6,.5,1)] group-data-[checked]/ws:[r:2.2px] group-data-[state=checked]/ws:[r:2.2px]"
                  />
                </svg>
                <span className="min-w-0 flex-1 truncate text-start">{w.name}</span>
                <span className="flex-none font-mono text-[10px] font-normal tracking-[0.04em] whitespace-nowrap text-muted-foreground">
                  {w.agents ? `${w.env} · ${w.agents}` : w.env}
                </span>
              </P.RadioItem>
            ))}
          </P.RadioGroup>
        </Section>
      )}

      {usage && (
        <Section k={k++} className="flex flex-col gap-2 px-3.5 pt-3 pb-2.5">
          <div className={cn(micro, "flex justify-between")}>
            <span className="whitespace-nowrap">{usage.title ?? "TOKENS · DAILY"}</span>
            <span className="whitespace-nowrap">
              <span className="text-[11px] text-foreground">{usage.used}</span> / {usage.cap}
            </span>
          </div>
          <UsagePlot usage={usage} />
          {usage.axis && (
            <div className="flex flex-wrap justify-between font-mono text-[10px] tracking-[0.08em] text-muted-foreground gap-x-3 gap-y-1">
              {usage.axis.map((a, i) => (
                <span key={i}>{a}</span>
              ))}
            </div>
          )}
        </Section>
      )}

      {cli && (
        <Section k={k++} className="flex items-center gap-2.5 px-3.5 py-2.5">
          <span className="flex h-7 min-w-0 flex-1 items-center gap-2 overflow-hidden border border-solid border-[var(--notchset-rule,var(--border))] bg-[var(--notchset-faceplate,var(--card))] px-2.5 font-mono text-[11px] whitespace-nowrap">
            <span className="text-muted-foreground">$</span>
            {cli}
          </span>
          <P.Item
            keepOpen
            aria-label={L.copyAria}
            onAction={(el) => {
              void copyText(cli).then((ok) => {
                playCue(el, ok ? "tick" : "signal");
                if (ok) setCopied(true);
              });
            }}
            className={cn(
              `notchset-focus`,
              "flex h-7 min-w-[66px] cursor-pointer items-center justify-center gap-1.5 border border-solid border-foreground px-2 font-mono text-[11px] font-medium tracking-[0.08em] outline-none transition-[background-color,color] duration-(--notchset-fade) ease-(--notchset-ease-fade) pointer-coarse:h-11",
              copied ? "bg-foreground text-background" : "bg-transparent text-foreground data-[highlighted]:bg-accent",
            )}
          >
            {copied ? L.copied : L.copy}
          </P.Item>
        </Section>
      )}

      {links.length > 0 && (
        <div className="relative py-1 motion-safe:animate-[notchset-fade-in_180ms_linear_calc(150ms+var(--k)*45ms)_both,notchset-drop_380ms_var(--notchset-ease-key-up)_calc(150ms+var(--k)*45ms)_both]" style={{ "--k": k++ } as React.CSSProperties}>
          <span
            aria-hidden
            className="absolute inset-x-0 bg-[var(--notchset-faceplate,var(--card))] transition-[top,height,opacity] duration-[var(--notchset-travel),var(--notchset-travel),var(--notchset-fade)] ease-[var(--notchset-ease-glide),var(--notchset-ease-glide),var(--notchset-ease-fade)]"
            style={{ top: highlight?.top ?? 4, height: highlight?.height ?? 32, opacity: highlight ? 1 : 0 }}
          />
          {links.map((l, i) => (
            <P.Item
              key={l.id}
              onFocus={(e) => setHighlight({ i, top: e.currentTarget.offsetTop, height: e.currentTarget.offsetHeight })}
              onBlur={() => setHighlight((h) => (h?.i === i ? null : h))}
              onAction={(el) => {
                playCue(el, "tick");
                l.onSelect?.();
              }}
              className={cn(`notchset-focus`, "group/link [--notchset-focus-inset:-2px] relative flex h-8 w-full cursor-pointer items-center gap-2.5 px-3.5 text-foreground outline-none pointer-coarse:h-11")}
            >
              <span aria-hidden className="size-[5px] scale-0 rounded-full bg-foreground transition-transform duration-[220ms] ease-[cubic-bezier(.34,1.3,.5,1)] group-data-[highlighted]/link:scale-100" />
              <span className="flex-1 font-mono text-[11px] font-medium tracking-[0.08em]">{l.label}</span>
              {l.hint && <span className="font-mono text-[10px] tracking-[0.04em] text-muted-foreground">{l.hint}</span>}
            </P.Item>
          ))}
        </div>
      )}

      <div className="grid grid-cols-[auto_1fr] border-t border-solid border-[var(--notchset-rule,var(--border))] motion-safe:animate-[notchset-fade-in_180ms_linear_calc(150ms+var(--k)*45ms)_both]" style={{ "--k": k } as React.CSSProperties}>
        {onThemeChange && (
          <P.RadioGroup value={theme ?? "system"} onValueChange={(v) => onThemeChange(v as UserMenuTheme)} aria-label={L.theme} className="relative my-2 ms-3.5 flex self-center border border-solid border-foreground">
            <span
              aria-hidden
              className="absolute inset-y-0 start-0 w-[30px] bg-foreground transition-transform duration-300 ease-[cubic-bezier(.34,1.3,.5,1)] rtl:-scale-x-100"
              style={{ transform: `translateX(${THEMES.indexOf(theme ?? "system") * 30}px)` }}
            />
            {THEMES.map((t) => (
              <P.RadioItem
                key={t}
                value={t}
                aria-label={L.themes[t]}
                className={cn(
                  `notchset-focus`,
                  "[--notchset-focus-inset:-3px] relative flex h-[26px] w-[30px] cursor-pointer items-center justify-center text-foreground outline-none transition-[color] delay-100 duration-[120ms] ease-linear data-[checked]:text-background data-[state=checked]:text-background",
                )}
              >
                <svg aria-hidden width="12" height="12" viewBox="0 0 12 12">
                  <circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
                  {THEME_GLYPH[t] && <path d={THEME_GLYPH[t]} fill="currentColor" />}
                </svg>
              </P.RadioItem>
            ))}
          </P.RadioGroup>
        )}
        {onSignOut && (
          <P.Item
            keepOpen
            onAction={async (el) => {
              if (signingOut) return;
              playCue(el, "tap");
              setSigningOut(true);
              setSignOutFailed(false);
              // Closes only once signed out; a failure stays open, says so, and offers the retry.
              try {
                await onSignOut();
                close();
              } catch {
                playCue(el, "signal");
                setSignOutFailed(true);
              } finally {
                setSigningOut(false);
              }
            }}
            className={cn(
              `notchset-focus`,
              "[--notchset-focus-inset:-2px] col-start-2 ms-3.5 flex h-[42px] cursor-pointer items-center justify-end gap-2.5 border-s border-solid border-[var(--notchset-rule,var(--border))] px-3.5 font-mono text-[11px] font-medium tracking-[0.08em] text-[var(--notchset-signal-text,var(--destructive))] outline-none transition-[background-color] duration-(--notchset-fade) ease-(--notchset-ease-fade) data-[highlighted]:bg-[var(--notchset-tint,var(--accent))] pointer-coarse:h-11",
              signingOut && "bg-[var(--notchset-tint,var(--accent))]",
            )}
          >
            {signingOut && <Scanner height={11} />}
            <span aria-live="polite">{signingOut ? L.signingOut : signOutFailed ? L.signOutFailed : L.signOut}</span>
            {L.signOutKbd && <span className="font-mono text-[10px] font-normal text-muted-foreground">{L.signOutKbd}</span>}
          </P.Item>
        )}
      </div>
    </div>
  );
}
```

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