# Avatar Group (Notchset): prompt.md (v1.0.0)

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

Overlapping avatars cut from each other by background rings, a dashed +N chip whose count rolls, members that glide, pop in and shrink out, and a callout that names whoever you hover or tab to.

## 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/avatar-group.tsx`, `components/ui/notchset/avatar.tsx`; shared code: `lib/beautiful-ui/notchset/instrument.tsx`, `lib/beautiful-ui/notchset/root.ts`, `lib/beautiful-ui/notchset/calendar.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `avatar`, `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: `AvatarGroup`, `Avatar`, `AvatarBadge`, `AvatarFallback`, `AvatarGroupCount`, `AvatarImage`, `initials`, `toneFor`, `PresenceNode`, 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 { AvatarGroup, type GroupPerson } from "@/components/ui/notchset/avatar-group";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `AvatarGroup` | `avatar-group` | The group. |

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/avatar-group.tsx`, `components/ui/notchset/avatar.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
- avatar group, avatar stack, reviewers, participants, members, facepile, Notchset
- Who's on something: reviewers, viewers, assignees

### Not when
- A full member list: use a table

## Mistakes
- Keep ids stable so members animate rather than remount

## Usage

```tsx
import { AvatarGroup, type GroupPerson } from "@/components/ui/notchset/avatar-group";

const reviewers: GroupPerson[] = [
  { id: "mk", name: "Maya Kerr", role: "ML lead", presence: "on" },
  { id: "jw", name: "Jonas Weber", role: "Evals", presence: "away" },
  { id: "ar", name: "Ana Ruiz", role: "Infra" },
  { id: "lp", name: "Lee Park", role: "Design" },
  { id: "so", name: "Sam Osei", role: "Support" },
];

export function Reviewers() {
  return <AvatarGroup aria-label="Reviewers" people={reviewers} max={4} />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `people` | `{ id, name, role?, presence?, tone? }[]` |  | In order; the first max are shown. |
| `max` | `number` |  | Avatars before +N (4). |

Full docs: https://notchset.dev/components/avatar-group

## 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 |
|---|---|
| Tab | Each avatar, then +N, naming each in the callout |

## Performance

- Absolutely placed; changes are left, scale and opacity.

## Responsive

- Width follows the count.

## Motion inventory

| Interaction | What moves |
|---|---|
| Add | The new avatar pops from 40% (380ms, +80ms); the group widens (420ms) |
| Remove | It shrinks away (220ms); the rest glide (420ms) |
| Callout | Glides between avatars (260ms) |

## Accessibility contract (preserve when editing)
- A labelled group; every avatar is a focusable, labelled button
- Hidden members are aria-hidden and unfocusable; the chip names them

## Install

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

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

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

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

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

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

## Source (Base UI build)

### components/ui/notchset/avatar-group.tsx

```tsx
"use client";

/**
 * Avatar Group (Notchset) v1.0.0 · Notchset
 * Docs: https://notchset.dev/components/avatar-group
 * MIT licensed: free to use, change and share.
 */

import * as React from "react";
import { cn } from "@/lib/utils";
import { NOTCHSET_ROOT, GLIDE } from "@/lib/beautiful-ui/notchset/instrument";
import { RollingLabel } from "@/lib/beautiful-ui/notchset/calendar";
import { type Presence, PresenceNode, initials, toneFor } from "@/components/ui/notchset/avatar";

/*
 * Notchset Avatar Group (no primitive, one file for both builds). 40px avatars at a 33px
 * step, each cut from its neighbour by a 2px ring of background, earlier ones on top. Past `max`, a
 * dashed +N chip with a rolling count. Members glide to their places (420ms), arrive popping from 40%
 * and leave shrinking. Hover or focus names an avatar in a callout that glides between them; on the
 * chip it lists who's hidden.
 */

const POP = "cubic-bezier(.34,1.9,.5,1)";
const TRACE = "cubic-bezier(.7,0,.25,1)";
const SETTLE = "cubic-bezier(.34,1.3,.5,1)";
const STEP = 33;
const SZ = 40;

export type GroupPerson = { id: string; name: string; role?: string; presence?: Presence; tone?: "ink" | "line" | "soft" };
const TONE = {
  ink: "border-foreground bg-foreground text-background",
  line: "border-foreground bg-background text-foreground",
  soft: "border-[var(--notchset-received-hover,var(--accent))] bg-[var(--notchset-received-hover,var(--accent))] text-foreground",
};

function AvatarGroup({ people, max: maxProp = 4, className, "aria-label": ariaLabel = "People" }: { people: readonly GroupPerson[]; max?: number; className?: string; "aria-label"?: string }) {
  // At least one face shows; a fractional or missing max is read as the whole number below it (or 4).
  const max = Number.isFinite(maxProp) ? Math.max(1, Math.trunc(maxProp)) : 4;
  const [hover, setHover] = React.useState<number | null>(null);
  const [ghosts, setGhosts] = React.useState<{ p: GroupPerson; at: number }[]>([]);
  const [prev, setPrev] = React.useState(people);
  const [fresh, setFresh] = React.useState<Set<string>>(new Set());
  if (prev !== people) {
    const gone = prev.map((p, i) => ({ p, at: i })).filter(({ p }) => !people.some((x) => x.id === p.id) && prev.indexOf(p) < max);
    const born = people.filter((p) => !prev.some((x) => x.id === p.id)).map((p) => p.id);
    setPrev(people);
    setGhosts((g) => [...g.filter((x) => !people.some((p) => p.id === x.p.id)), ...gone]);
    setFresh(new Set(born));
  }
  React.useEffect(() => {
    if (!ghosts.length) return;
    const t = setTimeout(() => setGhosts([]), 240);
    return () => clearTimeout(t);
  }, [ghosts]);

  const n = people.length;
  const shown = Math.min(n, max);
  const chip = n > max;
  const hidden = people.slice(max);
  const width = (shown + (chip ? 1 : 0) - 1) * STEP + SZ;
  const callout = hover == null ? "" : hover === -1 ? (chip ? `+${n - max} · ${hidden.map((p) => p.name).join(", ").toUpperCase()}` : "") : people[hover] ? `${people[hover]!.name.toUpperCase()}${people[hover]!.role ? ` · ${people[hover]!.role}` : ""}` : "";
  const calloutX = (hover === -1 ? max : Math.max(0, Math.min(hover ?? 0, max))) * STEP + SZ / 2;

  const face = (p: GroupPerson) => (
    <>
      <span className={cn("absolute inset-0 flex items-center justify-center rounded-full border border-solid font-mono text-xs font-medium tracking-[0.02em]", TONE[p.tone ?? toneFor(p.id)])}>{initials(p.name)}</span>
      {p.presence && <PresenceNode presence={p.presence} node={9} inset={1} />}
    </>
  );
  const ring = cn(`notchset-focus`, "absolute top-0 size-10 rounded-full border-2 border-solid border-background bg-background p-0 [--notchset-focus-inset:-5px]");

  return (
    <div
      {...NOTCHSET_ROOT}
      role="group"
      aria-label={ariaLabel}
      data-slot="avatar-group"
      onMouseLeave={() => setHover(null)}
      onFocus={(e) => {
        const g = (e.target as HTMLElement).getAttribute("data-gi");
        if (g != null) setHover(Number(g));
      }}
      onBlur={(e) => {
        if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) setHover(null);
      }}
      className={cn("relative h-10 text-foreground", className)}
      style={{ width, transition: `width 420ms ${GLIDE}` }}
    >
      <div
        aria-hidden
        // Centred over its face from the start edge, so the callout follows the faces in RTL too.
        className="pointer-events-none absolute bottom-[calc(100%+6px)] flex -translate-x-1/2 flex-col items-center rtl:translate-x-1/2"
        style={{ insetInlineStart: calloutX, opacity: callout ? 1 : 0, transform: `translateY(${callout ? 0 : 4}px)`, transition: `inset-inline-start 260ms ${GLIDE}, opacity 140ms linear, transform 260ms ${SETTLE}` }}
      >
        <span className="flex h-5 items-center border border-solid border-foreground bg-background px-[7px] font-mono text-[10px] font-medium tracking-[0.08em] whitespace-nowrap">{callout || " "}</span>
        <span className="h-2 w-px bg-foreground" />
      </div>
      {people.map((p, i) => {
        const vis = i < shown;
        const born = fresh.has(p.id);
        return (
          <button
            key={p.id}
            type="button"
            data-gi={i}
            tabIndex={vis ? 0 : -1}
            aria-hidden={!vis || undefined}
            aria-label={`${p.name}${p.role ? `, ${p.role.toLowerCase()}` : ""}${p.presence ? `, ${{ on: "online", away: "away", off: "offline" }[p.presence]}` : ""}`}
            onMouseEnter={() => setHover(i)}
            className={cn(ring, "cursor-default", !vis && "pointer-events-none", vis && born && "animate-[notchset-avatar-in_380ms_cubic-bezier(.34,1.9,.5,1)_80ms_both]")}
            style={{
              insetInlineStart: Math.min(i, max) * STEP,
              zIndex: 20 - i,
              opacity: vis ? 1 : 0,
              transform: `scale(${vis ? 1 : 0.4})`,
              transition: `inset-inline-start 420ms ${GLIDE}, transform ${vis ? 380 : 220}ms ${vis ? POP : TRACE}, opacity ${vis ? 200 : 140}ms linear`,
            }}
          >
            {face(p)}
          </button>
        );
      })}
      {ghosts.map(({ p, at }) => (
        <span key={`ghost-${p.id}`} aria-hidden className={cn(ring, "pointer-events-none animate-[notchset-avatar-out_220ms_cubic-bezier(.7,0,.25,1)_both]")} style={{ insetInlineStart: Math.min(at, max) * STEP, zIndex: 20 - at }}>
          {face(p)}
        </span>
      ))}
      <button
        type="button"
        data-gi={-1}
        tabIndex={chip ? 0 : -1}
        aria-hidden={!chip || undefined}
        aria-label={`${n - max} more: ${hidden.map((p) => p.name).join(", ")}`}
        onMouseEnter={() => setHover(-1)}
        className={cn(ring, "z-[1] cursor-default", !chip && "pointer-events-none")}
        style={{ insetInlineStart: max * STEP, opacity: chip ? 1 : 0, transform: `scale(${chip ? 1 : 0.4})`, transition: `transform 380ms ${chip ? POP : TRACE}, opacity 200ms linear` }}
      >
        <span className="absolute inset-0 flex items-center justify-center rounded-full border border-dashed border-[var(--notchset-control-edge,var(--input))] bg-[var(--notchset-faceplate,var(--muted))] font-mono text-xs font-medium">
          +<RollingLabel text={String(Math.max(1, n - max))} height={14} delay={60} className="text-[12px]" />
        </span>
      </button>
    </div>
  );
}

export { AvatarGroup };
```

### components/ui/notchset/avatar.tsx

```tsx
"use client";

/**
 * Avatar Group (Notchset) v1.0.0 · Notchset
 * Docs: https://notchset.dev/components/avatar-group
 * MIT licensed: free to use, change and share.
 */

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

/*
 * Notchset Avatar (no primitive, one file for both builds). Round, in three sizes
 * (28, 40, 56px) with mono initials at a third of the size, in a tone picked from the person's id
 * (ink, line, soft), or an agent's reticle. A photo cross-fades over the initials once it loads and
 * falls back on error. Presence sits bottom-right: a background halo cuts the node out of the edge;
 * on is filled, away a ring, off shrinks away (320ms pop).
 */

const POP = "cubic-bezier(.34,1.9,.5,1)";
const SIZES = { s: [28, 6], m: [40, 8], l: [56, 11], sm: [28, 6], default: [40, 8], lg: [56, 11] } as const;
type AvatarSize = keyof typeof SIZES;
export type Presence = "on" | "away" | "off";
export type AvatarTone = "ink" | "line" | "soft";

export const initials = (name: string) =>
  name
    .split(/\s+/)
    .filter(Boolean)
    .map((w) => w[0]!.toUpperCase())
    .slice(0, 2)
    .join("");
/** A tone from the id, so a person keeps theirs everywhere. */
export const toneFor = (id: string): AvatarTone => (["ink", "line", "soft"] as const)[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % 3]!;

const TONE: Record<AvatarTone | "agent", string> = {
  ink: "border-foreground bg-foreground text-background",
  line: "border-foreground bg-background text-foreground",
  soft: "border-[var(--notchset-received-hover,var(--accent))] bg-[var(--notchset-received-hover,var(--accent))] text-foreground",
  agent: "border-foreground bg-transparent text-foreground",
};

/** The presence node and its halo; `inset` moves it inside the circle (groups). */
export function PresenceNode({ presence, node, inset }: { presence: Presence; node: number; inset?: number }) {
  const r = node / 2;
  const halo = node <= 6 ? 1.5 : 2;
  const off = inset ?? (node <= 6 ? -2 : node >= 11 ? 0 : -1);
  const t = `r 320ms ${POP}, fill 160ms linear, stroke-width 160ms linear`;
  return (
    <svg aria-hidden width={node + 4} height={node + 4} className="absolute overflow-visible" style={{ insetInlineEnd: off, bottom: off }}>
      <circle cx="50%" cy="50%" className="fill-background" style={{ r: presence === "off" ? 0 : r + halo, transition: t }} />
      <circle cx="50%" cy="50%" className={cn("stroke-foreground", presence === "on" ? "fill-foreground" : "fill-background")} strokeWidth={presence === "away" ? 1.2 : 0} style={{ r: presence === "off" ? 0 : presence === "away" ? r - 0.6 : r, transition: t }} />
    </svg>
  );
}

function AgentReticle() {
  return (
    <svg aria-hidden width="100%" height="100%" viewBox="0 0 40 40">
      <circle cx="20" cy="20" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
      <circle cx="20" cy="20" r="2.6" fill="currentColor" />
      <path fill="none" d="M20 4 V9 M20 31 V36 M4 20 H9 M31 20 H36" strokeWidth="1" className="stroke-[var(--notchset-control-edge,var(--input))]" />
    </svg>
  );
}

/*
 * shadcn's compound Avatar: <Avatar><AvatarImage src alt /><AvatarFallback>JO</AvatarFallback></Avatar>,
 * sizes sm/default/lg. The image shows once it has loaded; until then (or if it fails) the fallback does.
 */
const AvatarImageState = React.createContext<{ status: "idle" | "loaded" | "failed"; set: (s: "idle" | "loaded" | "failed") => void } | null>(null);

function AvatarImage({ className, src, onLoad, onError, ...props }: React.ComponentProps<"img">) {
  const ctx = React.useContext(AvatarImageState);
  if (!src || ctx?.status === "failed") return null;
  return (
    <img
      alt=""
      data-slot="avatar-image"
      src={src}
      onLoad={(e) => {
        ctx?.set("loaded");
        onLoad?.(e);
      }}
      onError={(e) => {
        ctx?.set("failed");
        onError?.(e);
      }}
      className={cn("absolute inset-0 size-full rounded-full object-cover transition-opacity duration-(--notchset-fade) ease-(--notchset-ease-fade)", className)}
      style={{ opacity: ctx?.status === "loaded" ? 1 : 0 }}
      {...props}
    />
  );
}

function AvatarFallback({ className, ...props }: React.ComponentProps<"span">) {
  const ctx = React.useContext(AvatarImageState);
  if (ctx?.status === "loaded") return null;
  return <span data-slot="avatar-fallback" className={cn("flex size-full items-center justify-center rounded-full", className)} {...props} />;
}

/** A small mark on the avatar's edge (a status or a count), as shadcn's AvatarBadge. */
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
  return <span data-slot="avatar-badge" className={cn("absolute -end-0.5 -bottom-0.5 flex size-3 items-center justify-center rounded-full bg-foreground text-background ring-2 ring-background", className)} {...props} />;
}

function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="avatar-group" className={cn("flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", className)} {...props} />;
}

function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="avatar-group-count"
      className={cn("relative flex size-10 flex-none items-center justify-center rounded-full border border-dashed border-foreground bg-background font-mono text-[11px] font-medium tabular-nums ring-2 ring-background", className)}
      {...props}
    />
  );
}

function CompoundAvatar({ size = "default", className, children, style, ...props }: React.ComponentProps<"span"> & { size?: AvatarSize }) {
  const [status, set] = React.useState<"idle" | "loaded" | "failed">("idle");
  const [px] = SIZES[size];
  return (
    <AvatarImageState value={{ status, set }}>
      <span
        {...NOTCHSET_ROOT}
        data-slot="avatar"
        data-size={size}
        className={cn("relative inline-flex flex-none items-center justify-center rounded-full border border-solid border-foreground bg-background font-mono font-medium tracking-[0.02em] text-foreground", className)}
        style={{ width: px, height: px, fontSize: Math.max(10, Math.round(px * 0.32)), ...style }}
        {...props}
      >
        {children}
      </span>
    </AvatarImageState>
  );
}

type NamedAvatarProps = Omit<React.ComponentProps<"span">, "children"> & {
  name: string;
  src?: string;
  size?: AvatarSize;
  /** Default: picked from the name. */
  tone?: AvatarTone;
  kind?: "person" | "agent";
  presence?: Presence;
};

/** With `name`: Notchset's avatar (initials, tone, presence, agent). With children: shadcn's compound avatar. */
function Avatar(props: NamedAvatarProps | (React.ComponentProps<"span"> & { size?: AvatarSize; name?: undefined })) {
  if (props.name === undefined) return <CompoundAvatar {...(props as React.ComponentProps<"span"> & { size?: AvatarSize })} />;
  return <NamedAvatar {...(props as NamedAvatarProps)} />;
}

function NamedAvatar({ name, src, size = "m", tone, kind = "person", presence, className, ...props }: NamedAvatarProps) {
  const [px, node] = SIZES[size];
  // The image's state belongs to the src it was for: a new src is tried afresh.
  const [img, setImg] = React.useState<{ src?: string; state: "loaded" | "failed" } | null>(null);
  const loaded = img?.src === src && img?.state === "loaded";
  const failed = img?.src === src && img?.state === "failed";
  const label = presence ? `${name}, ${kind === "agent" ? { on: "active", away: "idle", off: "paused" }[presence] : { on: "online", away: "away", off: "offline" }[presence]}` : name;
  return (
    <span
      {...NOTCHSET_ROOT}
      role="img"
      aria-label={label}
      data-slot="avatar"
      data-size={size}
      className={cn("relative inline-flex flex-none items-center justify-center rounded-full border border-solid font-mono font-medium tracking-[0.02em]", TONE[kind === "agent" ? "agent" : (tone ?? toneFor(name))], className)}
      style={{ width: px, height: px, fontSize: Math.max(10, Math.round(px * 0.32)) }}
      {...props}
    >
      {kind === "agent" ? <AgentReticle /> : <span aria-hidden>{initials(name)}</span>}
      {src && !failed && (
        <img
          src={src}
          alt=""
          onLoad={() => setImg({ src, state: "loaded" })}
          onError={() => setImg({ src, state: "failed" })}
          className="absolute inset-0 size-full rounded-full object-cover transition-opacity duration-(--notchset-fade) ease-(--notchset-ease-fade)"
          style={{ opacity: loaded ? 1 : 0 }}
        />
      )}
      {presence && <PresenceNode presence={presence} node={node} />}
    </span>
  );
}

export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
```

### 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;
```

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

```tsx
"use client";

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

/*
 * Notchset calendar core, shared by Calendar, Date Picker and Date Range Picker. Days
 * are whole numbers (days since 1970-01-01 of the local date), so moving and comparing is arithmetic.
 * A 6-week grid of square cells (44px or 40px) starting on Monday, with an optional ISO week column.
 * Single mode has one ink block that stretches to the picked day: the leading edge moves first and
 * the trailing edge 85ms later (300ms). Range mode paints its two ends ink and the days between,
 * rippling out from the start. A month change slides the grid 14px away and fades (110ms), then
 * settles it in from the other side (380ms) while the title rolls 8px.
 */

const TRAVEL = "cubic-bezier(.7,0,.25,1)";
const SETTLE = "cubic-bezier(.34,1.3,.5,1)";

export type Day = number;
/** The UTC time of a calendar date. Date.UTC would read years 0–99 as 1900–1999; setUTCFullYear doesn't. */
const utc = (y: number, m: number, d: number) => new Date(0).setUTCFullYear(y, m, d);
export const toDay = (d: Date): Day => Math.round(utc(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);
export const dayOf = (y: number, m: number, d: number): Day => Math.round(utc(y, m, d) / 864e5);
export const fromDay = (n: Day) => {
  const u = new Date(n * 864e5);
  return new Date(u.getUTCFullYear(), u.getUTCMonth(), u.getUTCDate());
};
/** [year, month (0–11), date, weekday (Monday 0 … Sunday 6)]. */
export const fields = (n: Day): [number, number, number, number] => {
  const u = new Date(n * 864e5);
  return [u.getUTCFullYear(), u.getUTCMonth(), u.getUTCDate(), (u.getUTCDay() + 6) % 7];
};
export const isoDay = (n: Day) => {
  const [y, m, d] = fields(n);
  return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
};
/** Parses YYYY-MM-DD (month and day may be one digit); null if it is not a real date. */
export const parseIsoDay = (t: string): Day | null => {
  const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t.trim());
  if (!m) return null;
  const y = +m[1]!;
  const mo = +m[2]! - 1;
  const d = +m[3]!;
  if (mo < 0 || mo > 11 || d < 1 || d > 31) return null;
  const n = dayOf(y, mo, d);
  return fields(n)[2] === d ? n : null;
};
/** ISO 8601 week number. */
export const isoWeek = (n: Day) => {
  const wd = fields(n)[3];
  const th = n - wd + 3;
  const j4 = dayOf(fields(th)[0], 0, 4);
  return Math.floor((n - wd - (j4 - fields(j4)[3])) / 7) + 1;
};
/** A month as one number (year × 12 + month), for comparing and stepping. */
export const monthIndex = (n: Day) => {
  const [y, m] = fields(n);
  return y * 12 + m;
};

const noSubscribe = () => () => {};
/**
 * This month, hydration-safe: the server and the hydrating client agree on the UTC month, then React
 * re-renders with the browser's local month (no mismatch, even across a month boundary).
 */
export function useThisMonth(): number {
  return React.useSyncExternalStore(
    noSubscribe,
    () => monthIndex(toDay(new Date())),
    () => {
      const now = new Date();
      return now.getUTCFullYear() * 12 + now.getUTCMonth();
    },
  );
}

/** A month index kept inside optional bounds. */
export const clampMonth = (m: number, start?: number, end?: number) => Math.min(end ?? m, Math.max(start ?? m, m));

const monthStart = (mi: number) => dayOf(Math.floor(mi / 12), mi % 12, 1);

/** Today, as a day; null while rendering on the server, so the first client render matches it. */
const noop = () => () => {};
export function useToday(): Day | null {
  return React.useSyncExternalStore(
    noop,
    () => toDay(new Date()),
    () => null,
  );
}

const fmt = (locale: string, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...o });
export function monthTitle(mi: number, locale = "en-US") {
  return `${fmt(locale, { month: "long" }).format(new Date(monthStart(mi) * 864e5)).toUpperCase()} ${Math.floor(mi / 12)}`;
}
/** THU · OCT 08 · 2026 */
export function longLabel(n: Day, locale = "en-US") {
  const d = new Date(n * 864e5);
  const wd = fmt(locale, { weekday: "short" }).format(d).toUpperCase().replace(".", "");
  const mo = fmt(locale, { month: "short" }).format(d).toUpperCase().replace(".", "");
  const [y, , dd] = fields(n);
  return `${wd} · ${mo} ${String(dd).padStart(2, "0")} · ${y}`;
}
/** OCT 08 */
export function shortLabel(n: Day, locale = "en-US") {
  const mo = fmt(locale, { month: "short" }).format(new Date(n * 864e5)).toUpperCase().replace(".", "");
  return `${mo} ${String(fields(n)[2]).padStart(2, "0")}`;
}
/** "Today", "In 6 days", "3 days ago": the distance from today, in caps. */
export function relLabel(n: Day, today: Day | null) {
  if (today == null) return "";
  const d = n - today;
  if (!d) return "TODAY";
  const k = Math.abs(d);
  return d > 0 ? `IN ${k} DAY${k > 1 ? "S" : ""}` : `${k} DAY${k > 1 ? "S" : ""} AGO`;
}

export type CalendarEvents = Readonly<Record<string, readonly string[]>>;

export type CalendarGridProps = {
  size?: 44 | 40;
  locale?: string;
  /** Single: the picked day. */
  selected?: Day | null;
  /** Range: the two ends, in pick order (b is null while the second end is being chosen). */
  range?: { a: Day | null; b: Day | null } | null;
  onPick: (n: Day, el: HTMLElement | null) => void;
  /** The month shown (year × 12 + month) and its change. */
  month: number;
  onMonthChange: (mi: number) => void;
  /** First and last months that can be shown. */
  startMonth?: number;
  endMonth?: number;
  isDisabled?: (n: Day) => boolean;
  events?: CalendarEvents;
  eventLabel?: (count: number) => string;
  showWeekNumber?: boolean;
  showToday?: boolean;
  /** Focus the tabbable day when mounted (the pickers' popups). */
  autoFocus?: boolean;
  className?: string;
};

type Phase = "idle" | "out" | "pre" | "in";

export function CalendarGrid({
  size = 44,
  locale = "en-US",
  selected = null,
  range = null,
  onPick,
  month,
  onMonthChange,
  startMonth,
  endMonth,
  isDisabled,
  events,
  eventLabel = (n) => `${n} event${n > 1 ? "s" : ""}`,
  showWeekNumber = false,
  showToday = false,
  autoFocus = false,
  className,
}: CalendarGridProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const today = useToday();
  // The month on screen lags the asked-for month by the slide-out (110ms).
  const [view, setView] = React.useState<{ shown: number; ph: Phase; nd: 1 | -1; asked: number }>({ shown: month, ph: "idle", nd: 1, asked: month });
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
  React.useEffect(() => () => timers.current.forEach(clearTimeout), []);
  if (view.asked !== month) {
    // A month set by the owner (not by this grid's own controls) is shown at once.
    setView((v) => ({ ...v, asked: month, shown: month, ph: "idle" }));
  }
  React.useEffect(() => {
    const target = view.asked;
    timers.current.forEach(clearTimeout);
    const at = (ms: number, f: (v: typeof view) => typeof view) => setTimeout(() => setView((v) => (v.asked === target ? f(v) : v)), ms);
    timers.current = [
      at(110, (v) => (v.shown === target ? v : { ...v, shown: target, ph: "pre" })),
      at(140, (v) => (v.ph === "pre" ? { ...v, ph: "in" } : v)),
      at(560, (v) => (v.ph === "idle" ? v : { ...v, ph: "idle" })),
    ];
  }, [view.asked]);
  /** This grid's own month changes (arrows, keys, picking a day in the next month) slide. */
  const goTo = (mi: number) => {
    const still = motionOff(root.current);
    setView((v) =>
      still
        ? { ...v, asked: mi, shown: mi, ph: "idle" }
        : { ...v, asked: mi, nd: mi > v.asked ? 1 : -1, ph: v.shown === mi ? (v.ph === "idle" ? "idle" : "in") : "out" },
    );
    onMonthChange(mi);
  };

  const shown = view.shown;
  const start = monthStart(shown) - fields(monthStart(shown))[3];
  const inShown = (n: Day) => monthIndex(n) === shown;
  const dis = (n: Day) => Boolean(isDisabled?.(n));

  // The focus day: roving tabindex across the grid. Kept when it is in the shown month.
  const fallback = selected ?? range?.b ?? range?.a ?? (today != null && monthIndex(today) === shown ? today : monthStart(shown));
  const [fcState, setFc] = React.useState<Day | null>(null);
  // A focus day in the month being moved to is kept: its cell takes focus once the grid shows it.
  const fc = fcState != null && monthIndex(fcState) === view.asked ? fcState : inShown(fallback) ? fallback : monthStart(shown);
  const wantFocus = React.useRef(autoFocus);
  React.useLayoutEffect(() => {
    if (!wantFocus.current) return;
    const cell = root.current?.querySelector<HTMLElement>(`[role=gridcell][data-day="${fc}"]`);
    if (cell) {
      cell.focus({ preventScroll: true });
      wantFocus.current = false;
    }
  });

  // Selection block (single mode): edges in grid steps; which edges lead comes from the move.
  const [move, setMove] = React.useState({ dx: 0, dy: 0 });
  const S = size;
  const si = selected != null ? selected - start : -1;
  const blockOn = range == null && si >= 0 && si < 42;
  const jump = view.ph !== "idle";
  const edge = (lead: boolean) => `300ms ${GLIDE} ${lead ? 0 : 85}ms`;
  const blockStyle: React.CSSProperties = blockOn
    ? {
        // Columns shrink on narrow screens, so across is a share of the width; rows keep their height.
        // Logical sides, so the block sits on the selected day in right-to-left grids too.
        insetInlineStart: `${((si % 7) / 7) * 100}%`,
        top: Math.floor(si / 7) * S,
        insetInlineEnd: `${((6 - (si % 7)) / 7) * 100}%`,
        bottom: (5 - Math.floor(si / 7)) * S,
        opacity: 1,
        transition: jump ? "opacity 120ms linear" : `inset-inline-start ${edge(move.dx <= 0)}, inset-inline-end ${edge(move.dx >= 0)}, top ${edge(move.dy <= 0)}, bottom ${edge(move.dy >= 0)}, opacity 120ms linear`,
      }
    : { inset: 0, opacity: 0, transition: "none" };

  const pick = (n: Day, el: HTMLElement | null) => {
    if (dis(n)) return;
    playCue(el, "tick");
    setFc(n);
    if (range == null && selected != null && inShown(n)) {
      const o = selected - start;
      const k = n - start;
      setMove({ dx: Math.sign((k % 7) - (((o % 7) + 7) % 7)), dy: Math.sign(Math.floor(k / 7) - Math.floor(o / 7)) });
    } else setMove({ dx: 0, dy: 0 });
    if (!inShown(n)) {
      // A spillover day from a month outside the bounds isn't pickable.
      const m = monthIndex(n);
      if ((startMonth != null && m < startMonth) || (endMonth != null && m > endMonth)) return;
      goTo(m);
    }
    onPick(n, el);
  };

  const prevOff = startMonth != null && shown <= startMonth;
  const nextOff = endMonth != null && shown >= endMonth;
  const nav = (d: 1 | -1, el: HTMLElement) => {
    // Bounds apply to the month asked for, so presses during a slide can't walk past them.
    const to = view.asked + d;
    if ((startMonth != null && to < startMonth) || (endMonth != null && to > endMonth)) return;
    playCue(el, "tick");
    goTo(view.asked + d);
  };

  const clampMonth = (n: Day) => {
    if (startMonth != null && monthIndex(n) < startMonth) return monthStart(startMonth);
    if (endMonth != null && monthIndex(n) > endMonth) return monthStart(endMonth + 1) - 1;
    return n;
  };
  const onKeyDown = (e: React.KeyboardEvent) => {
    const steps: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
    let n = fc;
    if (steps[e.key] != null) n += (e.key === "ArrowLeft" || e.key === "ArrowRight") && getComputedStyle(e.currentTarget).direction === "rtl" ? -steps[e.key]! : steps[e.key]!;
    else if (e.key === "Home") n -= fields(n)[3];
    else if (e.key === "End") n += 6 - fields(n)[3];
    else if (e.key === "PageUp" || e.key === "PageDown") {
      const [y, m, d] = fields(n);
      const mi = y * 12 + m + (e.key === "PageUp" ? -1 : 1);
      const last = fields(monthStart(mi + 1) - 1)[2];
      n = dayOf(Math.floor(mi / 12), mi % 12, Math.min(d, last));
    } else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      pick(fc, e.target as HTMLElement);
      return;
    } else return;
    e.preventDefault();
    n = clampMonth(n);
    setFc(n);
    wantFocus.current = true;
    if (!inShown(n)) goTo(monthIndex(n));
  };

  // Range painting: ends ink; between them a hover tint while choosing, a fill once chosen.
  const [hover, setHover] = React.useState<Day | null>(null);
  const A = range?.a ?? null;
  const B = range?.b ?? (A != null ? hover : null);
  const lo = A != null && B != null ? Math.min(A, B) : A;
  const hi = A != null && B != null ? Math.max(A, B) : A;
  const complete = range?.b != null;

  const set = view.ph === "pre";
  const gridMotion: React.CSSProperties = {
    opacity: view.ph === "out" || view.ph === "pre" ? 0 : 1,
    transform: `translateX(${view.ph === "out" ? -view.nd * 14 : view.ph === "pre" ? view.nd * 14 : 0}px)`,
    transition: set ? "none" : view.ph === "out" ? `opacity 110ms linear, transform 110ms ${TRAVEL}` : `opacity 220ms linear, transform 380ms ${SETTLE}`,
  };
  const titleMotion: React.CSSProperties = {
    opacity: gridMotion.opacity,
    transform: `translateY(${view.ph === "out" ? -view.nd * 8 : view.ph === "pre" ? view.nd * 8 : 0}px)`,
    transition: gridMotion.transition,
  };
  const title = monthTitle(shown, locale);
  const wdFmt = fmt(locale, { weekday: "short" });
  const wdLong = fmt(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" });

  const cells = Array.from({ length: 42 }, (_, i) => start + i);
  const navClass =
    cn(`notchset-focus`, "[--notchset-focus-inset:-3px] flex size-7 flex-none cursor-pointer items-center justify-center border border-solid border-[var(--notchset-rule,var(--border))] bg-transparent p-0 text-foreground aria-disabled:cursor-not-allowed aria-disabled:text-[var(--notchset-rule,var(--border))]");

  return (
    <div ref={root} data-slot="calendar-grid" className={cn("flex min-w-0 flex-col gap-2", className)}>
      <div className="flex h-7 items-center gap-1.5">
        <span className="relative h-5 min-w-0 flex-1 overflow-hidden">
          <span aria-live="polite" className="absolute start-0 top-0 flex h-5 items-center font-mono text-[11px] font-medium tracking-[0.12em] whitespace-nowrap" style={titleMotion}>
            {title}
          </span>
        </span>
        {showToday && (
          <button
            {...OWN_SOUND}
            type="button"
            disabled={today == null}
            onClick={(e) => today != null && pick(today, e.currentTarget)}
            className={cn(`notchset-focus`, "[--notchset-focus-inset:-3px] h-7 cursor-pointer border-0 bg-transparent px-2 font-mono text-[11px] font-medium tracking-[0.1em] text-foreground underline underline-offset-[3px]")}
          >
            TODAY
          </button>
        )}
        <button {...OWN_SOUND} type="button" aria-label="Previous month" aria-disabled={prevOff || undefined} onClick={(e) => nav(-1, e.currentTarget)} className={navClass}>
          <svg aria-hidden width="10" height="10" viewBox="0 0 12 12" className="rtl:-scale-x-100">
            <path d="M7.5 2.5 L4 6 L7.5 9.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" />
          </svg>
        </button>
        <button {...OWN_SOUND} type="button" aria-label="Next month" aria-disabled={nextOff || undefined} onClick={(e) => nav(1, e.currentTarget)} className={navClass}>
          <svg aria-hidden width="10" height="10" viewBox="0 0 12 12" className="rtl:-scale-x-100">
            <path d="M4.5 2.5 L8 6 L4.5 9.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" />
          </svg>
        </button>
      </div>
      <div aria-hidden className="flex h-4 items-center border-0 border-b border-solid border-[var(--notchset-rule,var(--border))] pb-1.5">
        {showWeekNumber && <span className="w-[34px] flex-none" />}
        <div className="grid" style={{ gridTemplateColumns: `repeat(7, minmax(0, ${S}px))` }}>
          {cells.slice(0, 7).map((n, i) => (
            <span key={i} className="text-center font-mono text-[10px] tracking-[0.1em] text-muted-foreground">
              {wdFmt.format(new Date(n * 864e5)).slice(0, 2).toUpperCase()}
            </span>
          ))}
        </div>
      </div>
      <div className="flex">
        {showWeekNumber && (
          <div aria-hidden className="grid w-[34px] flex-none" style={{ gridAutoRows: S, opacity: gridMotion.opacity, transition: gridMotion.transition }}>
            {Array.from({ length: 6 }, (_, r) => (
              <span key={r} className="flex items-center font-mono text-[10px] tracking-[0.06em] text-muted-foreground">
                W{String(isoWeek(start + r * 7)).padStart(2, "0")}
              </span>
            ))}
          </div>
        )}
        <div
          role="grid"
          aria-label={title}
          aria-multiselectable={range != null || undefined}
          onKeyDown={onKeyDown}
          onMouseLeave={() => setHover(null)}
          className="relative grid min-w-0"
          style={{ gridTemplateColumns: `repeat(7, minmax(0, ${S}px))`, gridAutoRows: S, ...gridMotion }}
        >
          <span aria-hidden data-slot="calendar-block" className="pointer-events-none absolute bg-primary" style={blockStyle} />
          {Array.from({ length: 6 }, (_, r) => (
            <div key={r} role="row" className="contents">
              {cells.slice(r * 7, r * 7 + 7).map((n) => {
                const [, , d] = fields(n);
                const inM = inShown(n);
                const off = dis(n);
                const ev = events?.[isoDay(n)] ?? [];
                const isToday = n === today;
                const end = range != null && A != null && (n === A || n === range.b);
                const mid = range != null && lo != null && hi != null && n > lo && n < hi;
                const on = range != null ? end : n === selected;
                const dl = complete && lo != null ? Math.min(320, Math.abs(n - lo) * 10) : 0;
                return (
                  <button
                    key={n}
                    {...OWN_SOUND}
                    type="button"
                    role="gridcell"
                    data-day={n}
                    data-selected={on || undefined}
                    data-range-middle={mid || undefined}
                    tabIndex={n === fc ? 0 : -1}
                    aria-selected={on}
                    aria-disabled={off || undefined}
                    aria-current={isToday ? "date" : undefined}
                    aria-label={`${wdLong.format(new Date(n * 864e5))}${isToday ? ", today" : ""}${ev.length ? `, ${eventLabel(ev.length)}` : ""}`}
                    onClick={(e) => pick(n, e.currentTarget)}
                    onMouseEnter={() => !off && setHover(n)}
                    className={cn(
                      `notchset-focus`,
                      "relative flex cursor-pointer flex-col items-center justify-center gap-[3px] border-0 p-0 [--notchset-focus-inset:-2px]",
                      off ? "cursor-not-allowed text-[var(--notchset-rule,var(--border))]" : inM ? "text-foreground" : "text-muted-foreground",
                      on && "text-primary-foreground",
                      range != null && end ? "bg-primary" : mid ? (complete ? "bg-[var(--notchset-received-hover,var(--accent))]" : "bg-[var(--notchset-faceplate,var(--muted))]") : "bg-transparent",
                      !on && !mid && !off && "hover:bg-[var(--notchset-faceplate,var(--muted))]",
                    )}
                    style={{ height: S, transition: range != null ? `background-color 160ms linear ${dl}ms, color 160ms linear ${dl}ms` : `background-color 120ms linear, color 140ms linear ${on ? 140 : 0}ms` }}
                  >
                    <span
                      className={cn(
                        "flex h-[18px] w-[26px] items-center justify-center border border-solid font-mono text-[12px] tabular-nums transition-[border-color] duration-(--notchset-fade) ease-(--notchset-ease-fade)",
                        inM ? "font-medium" : "font-normal",
                        isToday ? (on ? "border-background" : "border-foreground") : "border-transparent",
                      )}
                    >
                      {d}
                    </span>
                    <svg aria-hidden width="14" height="3" viewBox="0 0 14 3" className="overflow-visible">
                      {ev.slice(0, 3).map((_, j, arr) => (
                        <circle key={j} cx={(arr.length === 1 ? [7] : arr.length === 2 ? [4.5, 9.5] : [2, 7, 12])[j]} cy="1.5" r="1.2" fill="currentColor" />
                      ))}
                    </svg>
                  </button>
                );
              })}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

/** The pickers' 40px trigger icon. */
export function CalendarIcon() {
  return (
    <svg aria-hidden width="13" height="13" viewBox="0 0 13 13" className="flex-none">
      <rect x="1.5" y="2.5" width="10" height="9" fill="none" stroke="currentColor" strokeWidth="1.1" />
      <path fill="none" d="M1.5 5.5 H11.5 M4.5 1 V3.5 M8.5 1 V3.5" stroke="currentColor" strokeWidth="1.1" />
    </svg>
  );
}

export function PickerChevron() {
  return (
    <svg
      aria-hidden
      width="9"
      height="9"
      viewBox="0 0 12 12"
      className="flex-none transition-transform duration-300 ease-[cubic-bezier(.34,1.5,.5,1)] group-data-[popup-open]/picker:rotate-180 group-data-[state=open]/picker:rotate-180"
    >
      <path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" />
    </svg>
  );
}

export const pickerTriggerClass = cn(
  "group/picker relative flex h-10 w-full min-w-0 cursor-pointer items-center gap-2.5 border border-solid border-[var(--notchset-control-edge,var(--input))] bg-background px-3 text-start text-foreground outline-none transition-[border-color] duration-(--notchset-fade) ease-(--notchset-ease-fade) [--notchset-focus-inset:-4px] pointer-coarse:h-11",
  "data-[popup-open]:border-foreground data-[state=open]:border-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40",
);

/** The 8px leader from the trigger down to the popup's edge, 16px in. */
export function PickerLeader() {
  return <span aria-hidden className="pointer-events-none absolute start-4 -top-[9px] h-2 w-px origin-top bg-foreground motion-safe:animate-[notchset-leader-y_100ms_var(--notchset-ease-travel)_both]" />;
}

/** The popup: an ink frame that unrolls (grid rows, 380ms after 80ms) and rolls up (180ms). */
export const pickerContentClass = cn(
  "relative z-50 grid 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]",
);

/** A 24px preset chip; ink when it is the current value. */
export function PresetChip({ on, className, ...props }: React.ComponentProps<"button"> & { on?: boolean }) {
  return (
    <button
      {...OWN_SOUND}
      type="button"
      aria-pressed={on}
      className={cn(`notchset-focus`, "[--notchset-focus-inset:-3px] h-6 cursor-pointer border border-solid px-[7px] font-mono text-[10px] font-medium tracking-[0.06em] whitespace-nowrap transition-[background-color,color] duration-(--notchset-fade) ease-(--notchset-ease-fade)",
        on ? "border-primary bg-primary text-primary-foreground" : "border-[var(--notchset-rule,var(--border))] bg-transparent text-foreground hover:bg-[var(--notchset-faceplate,var(--muted))]",
        className,
      )}
      {...props}
    />
  );
}

/** The 28px key legend under a picker's grid. */
export function PickerKeys({ keys }: { keys: readonly string[] }) {
  return (
    <div aria-hidden className="flex h-7 items-center gap-3.5 border-0 border-t border-solid border-[var(--notchset-rule,var(--border))] px-3 font-mono text-[10px] tracking-[0.1em] whitespace-nowrap text-muted-foreground">
      {keys.map((k) => (
        <span key={k}>{k}</span>
      ))}
    </div>
  );
}

export type DatePreset = { label: string; date: Date };

/** The date picker trigger's inside: icon, the date rolling digit by digit (ones first), chevron. */
export function DatePickerFace({ value, placeholder = "PICK A DATE", locale }: { value: Date | undefined; placeholder?: React.ReactNode; locale?: string }) {
  return (
    <>
      <CalendarIcon />
      <span className="flex min-w-0 flex-1">
        {value ? (
          <RollingLabel text={longLabel(toDay(value), locale)} />
        ) : (
          <span className="truncate font-mono text-[12px] leading-[18px] font-medium text-muted-foreground">{placeholder}</span>
        )}
      </span>
      <PickerChevron />
    </>
  );
}

/** Text whose digits roll, in the picker's mono label style (DigitRoll, from instrument). */
export function RollingLabel({ text, height = 18, delay = 0, className }: { text: string; height?: number; delay?: number; className?: string }) {
  return <DigitRoll text={text} height={height} delay={delay} className={cn("font-mono text-[12px] font-medium whitespace-pre", className)} />;
}

/** The date picker popup's inside: presets, the 40px grid, the key legend. */
export function DatePickerPanel({
  value,
  onPick,
  presets,
  disabled,
  startMonth,
  endMonth,
  events,
  eventLabel,
  locale,
}: {
  value: Date | undefined;
  onPick: (date: Date) => void;
  presets?: readonly DatePreset[];
  disabled?: (date: Date) => boolean;
  startMonth?: Date;
  endMonth?: Date;
  events?: CalendarEvents;
  eventLabel?: (count: number) => string;
  locale?: string;
}) {
  const sel = value ? toDay(value) : null;
  const thisMonth = useThisMonth();
  const [chosenMonth, setMonth] = React.useState<number | null>(() => (sel != null ? monthIndex(sel) : null));
  const month = chosenMonth ?? thisMonth;
  // A date chosen from outside (a preset elsewhere, a reset) brings its month into view.
  const [seenSel, setSeenSel] = React.useState(sel);
  if (sel !== seenSel) {
    setSeenSel(sel);
    if (sel != null && monthIndex(sel) !== month) setMonth(monthIndex(sel));
  }
  const isDisabled = disabled ? (n: Day) => disabled(fromDay(n)) : undefined;
  return (
    <div className="min-h-0 overflow-hidden">
      <div className="flex flex-col gap-2.5 px-3 pt-3 pb-1.5">
        {presets && presets.length > 0 && (
          <div className="flex gap-1">
            {presets.map((p) => {
              const n = toDay(p.date);
              return (
                <PresetChip
                  key={p.label}
                  on={n === sel}
                  disabled={isDisabled?.(n)}
                  onClick={(e) => {
                    playCue(e.currentTarget, "tick");
                    setMonth(monthIndex(n));
                    onPick(fromDay(n));
                  }}
                >
                  {p.label}
                </PresetChip>
              );
            })}
          </div>
        )}
        <CalendarGrid
          size={40}
          locale={locale}
          selected={sel}
          month={month}
          onMonthChange={setMonth}
          startMonth={startMonth ? monthIndex(toDay(startMonth)) : undefined}
          endMonth={endMonth ? monthIndex(toDay(endMonth)) : undefined}
          isDisabled={isDisabled}
          events={events}
          eventLabel={eventLabel}
          autoFocus
          onPick={(n) => onPick(fromDay(n))}
        />
      </div>
      <PickerKeys keys={["ARROWS MOVE", "PGUP / PGDN MONTH", "↵ PICK"]} />
    </div>
  );
}
```

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.
