# Operation (Notchset): prompt.md (v2.0.0)

- id: `operation` · version 2.0.0 · component · free
- category: Feedback
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/operation.json`
- npm dependencies: lucide-react@^1
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json, @notchset/banner, @notchset/button, @notchset/spinner, @notchset/toast
- docs: https://notchset.dev/components/operation
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

useOperation runs a change: pending, done with the stored record, or failed with a typed reason and Retry. The draft is kept until done, a late answer never overrides a newer one, optimistic changes roll back. OpStatus, OpBanner and toOpError show it..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: `lucide-react@^1`.
- Files: `components/ui/notchset/operation.tsx`; shared code: `lib/notchset/op-error.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `banner`, `button`, `spinner`, `toast`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `markRow`, `notifyCreated`, `OpBanner`, `opVerb`, `OpStatus`, `useOperation`, `opError`, `OpFailure`, `opErrorLine`, `toOpError`, `OP_DEFAULTS`, `resetOperations`, `announce`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) 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.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-bad-ink`, `--ns-ease`, `--ns-ink`, `--ns-ink-3`, `--ns-text-meta`, `--ns-time-color`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { Button } from "@/components/ui/notchset/button";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/operation.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

### Not when

## Built from
- banner (`banner`), installed with it: reuse it, don't rebuild it
- button (`button`), installed with it: reuse it, don't rebuild it
- spinner (`spinner`), installed with it: reuse it, don't rebuild it
- toast (`toast`), installed with it: reuse it, don't rebuild it
- Op error (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/notchset/button";
import { CompanyMark } from "@/components/ui/notchset/company-mark";
import { opError, OpStatus, useOperation } from "@/components/ui/notchset/operation";
import { SegmentedControl } from "@/components/ui/notchset/segmented-control";

/* the demo's server: answers after a beat, or drops, or refuses; a real app passes its adapter's verb */
type Server = "answers" | "offline" | "refuses";
const wait = (ms: number, signal: AbortSignal) => new Promise<void>((done, stop) => {
  const t = setTimeout(done, ms);
  signal.addEventListener("abort", () => (clearTimeout(t), stop(signal.reason)), { once: true });
});

const STRIPE = "M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z";
const money = (n: number) => `$${n.toLocaleString("en-US")}`;

export default function OperationDemo() {
  const [server, setServer] = React.useState<Server>("offline");
  const [value, setValue] = React.useState(148_000);
  const save = useOperation(
    async (next: number, signal) => {
      await wait(900, signal);
      if (server === "offline") throw new TypeError("Failed to fetch");
      if (server === "refuses") throw opError("permission", "Your role can't change a deal's value.");
      return { id: "deal_42", value: next };
    },
    /* the stored record comes back; the page adopts it */
    { key: "demo:deal_42:value", onDone: (deal) => setValue(deal.value) },
  );
  /* the field keeps what you typed while it saves and after a refusal (the draft), until Undo */
  const shown = save.state === "pending" || save.state === "failed" ? (save.draft ?? value) : value;
  return (
    <div className="flex w-full max-w-[420px] flex-col gap-4">
      <SegmentedControl<Server>
        size="sm"
        label="The server"
        value={server}
        onValueChange={setServer}
        options={[
          { value: "answers", label: "Answers" },
          { value: "offline", label: "Offline" },
          { value: "refuses", label: "Refuses" },
        ]}
      />
      <div className="flex flex-col gap-2.5 rounded-[var(--ns-radius-lg)] bg-[var(--ns-panel)] p-3.5 shadow-[0_0_0_1px_var(--ns-line)]">
        <div className="flex items-center gap-2.5">
          <CompanyMark name="Stripe" color="#635bff" glyph={STRIPE} size={22} />
          <b className="flex-1 font-[number:var(--ns-weight-strong)]">Platform renewal</b>
        </div>
        <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
          <span className="w-16 text-[length:var(--ns-text-small)] text-[var(--ns-ink-3)]">Value</span>
          <span className={save.state === "failed" ? "rounded-[6px] px-1.5 text-[var(--ns-bad-ink)] tabular-nums shadow-[inset_0_0_0_1px_var(--ns-bad)]" : "px-1.5 tabular-nums"}>{money(shown)}</span>
          <OpStatus op={save} was={`Was ${money(value)}.`} onUndo={save.reset} />
        </div>
      </div>
      <div className="flex gap-2">
        <Button size="sm" disabled={save.pending} onClick={() => void save.run(shown + 16_000)}>
          Raise by $16,000
        </Button>
      </div>
    </div>
  );
}
```

This example also uses `company-mark` (https://notchset.dev/components/company-mark), `segmented-control` (https://notchset.dev/components/segmented-control). Installing this item doesn't bring them; to paste the example as it is: `npx shadcn@latest add https://notchset.dev/r/operation.json https://notchset.dev/r/company-mark.json https://notchset.dev/r/segmented-control.json`.

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `op` | `OpLike` |  | The operation it reports (from useOperation). |
| `pending` | `string` | `"Saving…"` | While it runs ("Saving…", "Archiving…"). |
| `done` | `string` | `"Saved"` | Said quietly for OP_DEFAULTS.savedWordMs once it lands ("Saved"). |
| `failed` | `string` | `"Not saved"` | Leads the reason when it fails ("Not saved", "Couldn't archive"). |
| `was` | `React.ReactNode` |  | What the value was before, so the person knows what Undo puts back ("Was $148,000."). |
| `onUndo` | `() => void` |  | Puts the old value back (offered beside Retry when it failed). |
| `savedMs` | `number` | `OP_DEFAULTS.savedWordMs` | How long "Saved" shows; 0 says nothing. Defaults to OP_DEFAULTS.savedWordMs. |

Full docs: https://notchset.dev/components/operation

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/operation.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/operation`. Every components.json style gets the same files.

## Continue the workflow

- [Banner](https://notchset.dev/components/banner)
- [Empty state](https://notchset.dev/components/empty-state)
- [Priority mark](https://notchset.dev/components/priority-mark)

## Source

### components/ui/notchset/operation.tsx

```tsx
// Notchset operation 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { Banner } from "@/components/ui/notchset/banner";
import { Button } from "@/components/ui/notchset/button";
import { opError, toOpError, type OpError } from "@/lib/notchset/op-error";
import { Spinner } from "@/components/ui/notchset/spinner";
import { notify } from "@/components/ui/notchset/toast";

/*
 * Operation: one lifecycle for every change a person makes (create, edit, delete, import, send). LANGUAGE.md §2
 * and §7, "Async work belongs to the operation".
 *
 *   const save = useOperation((v: string, signal) => adapter.updateDeal(id, { value: v }, { signal }), { key: `deal:${id}:value` });
 *   save.run("164000");        // pending → done (save.result is the stored record) or failed (save.error: OpError)
 *   <OpStatus op={save} was="Was $148,000." onUndo={…} />
 *
 * - The draft (the last input) is kept until the server acknowledges it; retry() sends it again.
 * - One operation per key: a second hook with the same key (a panel closed and opened again) sees the same
 *   operation, still running; a late answer, success or refusal, never overrides a newer run on that key.
 * - optimistic(input) applies the change at once and returns how to undo it; a refusal rolls back to the newest
 *   state not refused (a newer run still pending or done keeps its value).
 * - Pending, done and failed are announced politely, once, from one shared live region.
 * - A write has no timeout: past OP_DEFAULTS.slowMs the status says "Still saving…" and the operation keeps waiting
 *   (a timed-out write that is still running lands anyway, and Retry would make a duplicate). A caller may opt into
 *   timeoutMs only when it also passes the signal to its request, so the timeout truly aborts it.
 */

/**
 * The founder's three switches (Go-live concept, 28 Oct 2026). Each is one value, read by every piece below.
 * failedVerb "keep": a failed create keeps its footer verb ("Create deal") and offers Retry and Edit in the banner;
 * "retry" swaps the verb to "Try again" and keeps only Edit in the banner. savedWordMs: how long an inline field
 * says a quiet "Saved" (0: say nothing, the value is the proof). createdToast: a new row lands with its mark and a
 * toast with Open (false: the mark only).
 */
export const OP_DEFAULTS: { failedVerb: "keep" | "retry"; savedWordMs: number; createdToast: boolean; timeoutMs: number; slowMs: number } = {
  failedVerb: "keep",
  savedWordMs: 2000,
  createdToast: true,
  timeoutMs: 0,
  slowMs: 15_000,
};

export type OpState = "idle" | "pending" | "done" | "failed";

/** What a run came to: done with the stored record, failed with the reason, or stale (a newer run, or a reset, took its place). */
export type OpOutcome<R> = { state: "done"; result: R } | { state: "failed"; error: OpError } | { state: "stale" };

type Words<I, R> = {
  pending?: string | ((input: I) => string) | undefined;
  done?: string | ((result: R, input: I) => string) | undefined;
  failed?: string | ((error: OpError, input: I) => string) | undefined;
};

export type OperationOptions<I, R> = {
  /** One operation per key (a record id, "deal:42:value"): it outlives the component and is shared by every hook with the key. */
  key?: string | undefined;
  /** Polite live-region words; false says nothing (when the change is announced by what it changes). */
  announce?: Words<I, R> | false | undefined;
  /** Applies the change at once; returns how to put it back if the server refuses. */
  optimistic?: ((input: I) => (() => void) | void) | undefined;
  onDone?: ((result: R, input: I) => void) | undefined;
  onError?: ((error: OpError, input: I) => void) | undefined;
  /** Fails as a timeout after this long; 0 (the default) waits. Opt in only when `fn` passes its signal to the request, so the timeout really aborts it. */
  timeoutMs?: number | undefined;
};

export type Operation<I, R> = {
  state: OpState;
  pending: boolean;
  /** The last input, kept until the server acknowledges it. */
  draft: I | undefined;
  /** The stored record the server answered with. */
  result: R | undefined;
  /** The last failure; kept while a retry runs, cleared when one lands. */
  error: OpError | undefined;
  /** When the last answer arrived (ms), so a "Saved" word can time itself. */
  at: number | undefined;
  /** Still running after OP_DEFAULTS.slowMs: the status says "Still saving…". */
  slow: boolean;
  /** Retry is offered: failed with a retryable error. */
  canRetry: boolean;
  run: (input: I) => Promise<OpOutcome<R>>;
  retry: () => Promise<OpOutcome<R>> | undefined;
  /** Back to idle: drops the draft and the error, stops a run in flight (its answer lands nowhere). */
  reset: () => void;
  /** The validation line for one field, when the server put it there. */
  fieldError: (field: string) => string | undefined;
};

/* ---------- the shared store: one slot per key ---------- */

type Snap = { slow?: boolean | undefined; state: OpState; draft?: unknown; result?: unknown; error?: OpError | undefined; at?: number | undefined };
type Entry = { seq: number; status: "pending" | "done" | "failed"; rollback?: (() => void) | undefined };
type Handlers = { onDone?: ((r: unknown, i: unknown) => void) | undefined; onError?: ((e: OpError, i: unknown) => void) | undefined };
type Slot = { snap: Snap; listeners: Set<() => void>; seq: number; entries: Entry[]; ctrls: Set<AbortController>; handlers: React.RefObject<Handlers> | null; mounts: number; local: boolean };

const IDLE: Snap = { state: "idle" };
const slots = new Map<string, Slot>();

function slotOf(key: string, local: boolean): Slot {
  let s = slots.get(key);
  if (!s) slots.set(key, (s = { snap: IDLE, listeners: new Set(), seq: 0, entries: [], ctrls: new Set(), handlers: null, mounts: 0, local }));
  return s;
}
function put(s: Slot, snap: Snap) {
  s.snap = snap;
  for (const l of s.listeners) l();
}
/* a keyed slot nobody shows and nothing runs in is forgotten, unless it failed: reopening shows the failure and its draft */
function sweep(key: string, s: Slot) {
  if (s.mounts === 0 && s.listeners.size === 0 && s.ctrls.size === 0 && (s.local || s.snap.state !== "failed")) slots.delete(key);
}
/* the newest state not refused: undo refused runs from the newest down, stopping at one still pending or done */
function settle(s: Slot) {
  while (s.entries.length && s.entries.at(-1)!.status === "failed") s.entries.pop()!.rollback?.();
  if (!s.entries.some((e) => e.status === "pending")) s.entries = [];
}

/** Forgets every operation (tests, sign-out): nothing in flight lands afterwards. */
export function resetOperations() {
  for (const s of slots.values()) {
    s.seq++;
    for (const c of s.ctrls) c.abort();
  }
  slots.clear();
}

/* ---------- the one polite live region ---------- */

let live: HTMLElement | null = null;
/** Says words once, politely, from the shared live region (created on first use). */
export function announce(text: string) {
  if (typeof document === "undefined" || !text) return;
  if (!live || !live.isConnected) {
    live = document.createElement("div");
    live.setAttribute("role", "status");
    live.setAttribute("aria-live", "polite");
    live.setAttribute("data-slot", "op-announcer");
    Object.assign(live.style, { position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" });
    document.body.appendChild(live);
  }
  /* the same words twice still read twice */
  live.textContent = live.textContent === text ? `${text} ` : text;
}

const say = <A extends unknown[]>(w: string | ((...a: A) => string) | undefined, fallback: string, ...a: A) => (w === undefined ? fallback : typeof w === "function" ? w(...a) : w);

/* ---------- the hook ---------- */

// useLayoutEffect warns during server render; fall back to useEffect there.
const useIsoLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

function useOperation<I, R>(fn: (input: I, signal: AbortSignal) => Promise<R>, opts: OperationOptions<I, R> = {}): Operation<I, R> {
  const id = React.useId();
  const key = opts.key ?? `local:${id}`;
  const local = opts.key === undefined;
  const latest = React.useRef({ fn, opts });
  const handlers = React.useRef<Handlers>({});
  useIsoLayoutEffect(() => {
    latest.current = { fn, opts };
    handlers.current = { onDone: opts.onDone as Handlers["onDone"], onError: opts.onError as Handlers["onError"] };
  });
  React.useEffect(() => {
    const s = slotOf(key, local);
    s.mounts++;
    s.handlers = handlers;
    return () => {
      s.mounts--;
      if (s.handlers === handlers) s.handlers = null;
      sweep(key, s);
    };
  }, [key, local]);
  const snap = React.useSyncExternalStore(
    React.useCallback(
      (l: () => void) => {
        const s = slotOf(key, local);
        s.listeners.add(l);
        return () => {
          s.listeners.delete(l);
          sweep(key, s);
        };
      },
      [key, local],
    ),
    () => slots.get(key)?.snap ?? IDLE,
    () => IDLE,
  );

  const run = React.useCallback(
    async (input: I): Promise<OpOutcome<R>> => {
      const { fn: go, opts: o } = latest.current;
      const s = slotOf(key, local);
      const seq = ++s.seq;
      const entry: Entry = { seq, status: "pending", rollback: o.optimistic?.(input) ?? undefined };
      s.entries.push(entry);
      const words = o.announce === false ? null : (o.announce ?? {});
      /* the last reason stays readable while it runs again (a LoadError keeps its place until a retry lands) */
      put(s, { state: "pending", draft: input, result: s.snap.result, error: s.snap.error, at: s.snap.at });
      if (words) announce(say(words.pending, "Saving…", input));
      const ctrl = new AbortController();
      s.ctrls.add(ctrl);
      const ms = o.timeoutMs ?? OP_DEFAULTS.timeoutMs;
      const slowTimer = OP_DEFAULTS.slowMs > 0 ? setTimeout(() => { if (s.seq === seq && s.snap.state === "pending") put(s, { ...s.snap, slow: true }); }, OP_DEFAULTS.slowMs) : undefined;
      const timer = ms > 0 ? setTimeout(() => ctrl.abort(opError("timeout", `The server didn't answer within ${Math.round(ms / 1000)} seconds. Everything you typed is still here.`)), ms) : undefined;
      const stopped = new Promise<never>((_, reject) => ctrl.signal.addEventListener("abort", () => reject(ctrl.signal.reason), { once: true }));
      let outcome: { ok: true; r: R } | { ok: false; e: OpError };
      try {
        outcome = { ok: true, r: await Promise.race([go(input, ctrl.signal), stopped]) };
      } catch (err) {
        outcome = { ok: false, e: toOpError(err) };
      } finally {
        clearTimeout(timer);
        clearTimeout(slowTimer);
        s.ctrls.delete(ctrl);
      }
      entry.status = outcome.ok ? "done" : "failed";
      settle(s);
      const current = slots.get(key) === s && s.seq === seq;
      if (!current) {
        sweep(key, s);
        return { state: "stale" };
      }
      const h = s.handlers?.current;
      if (outcome.ok) {
        put(s, { state: "done", result: outcome.r, at: Date.now() });
        if (words) announce(say(words.done, "Saved", outcome.r, input));
        if (s.mounts > 0) h?.onDone?.(outcome.r, input);
        sweep(key, s);
        return { state: "done", result: outcome.r };
      }
      const e = outcome.e;
      put(s, { state: "failed", draft: input, result: s.snap.result, error: e, at: Date.now() });
      if (words) announce(say(words.failed, `Not saved: ${e.message}`, e, input));
      if (s.mounts > 0) h?.onError?.(e, input);
      sweep(key, s);
      return { state: "failed", error: e };
    },
    [key, local],
  );

  const reset = React.useCallback(() => {
    const s = slots.get(key);
    if (!s) return;
    s.seq++;
    for (const c of s.ctrls) c.abort(opError("unknown", "Stopped."));
    put(s, IDLE);
  }, [key]);

  const canRetry = snap.state === "failed" && !!snap.error?.retryable;
  const retry = React.useCallback(() => {
    const s = slots.get(key)?.snap;
    if (!s || s.state !== "failed" || !s.error?.retryable) return undefined;
    return run(s.draft as I);
  }, [key, run]);

  const error = snap.error;
  return {
    state: snap.state,
    pending: snap.state === "pending",
    slow: snap.state === "pending" && !!snap.slow,
    draft: snap.draft as I | undefined,
    result: snap.result as R | undefined,
    error,
    at: snap.at,
    canRetry,
    run,
    retry,
    reset,
    fieldError: (field: string) => (error && error.code === "validation" && error.field === field ? error.message : undefined),
  };
}

/* ---------- what an operation looks like ---------- */

type OpLike = Pick<Operation<never, unknown>, "state" | "error" | "canRetry" | "at"> & { slow?: boolean | undefined; retry: () => unknown };

type OpStatusProps = {
  /** The operation it reports (from useOperation). */
  op: OpLike;
  /** While it runs ("Saving…", "Archiving…"). */
  pending?: string | undefined;
  /** Said quietly for OP_DEFAULTS.savedWordMs once it lands ("Saved"). */
  done?: string | undefined;
  /** Leads the reason when it fails ("Not saved", "Couldn't archive"). */
  failed?: string | undefined;
  /** What the value was before, so the person knows what Undo puts back ("Was $148,000."). */
  was?: React.ReactNode;
  /** Puts the old value back (offered beside Retry when it failed). */
  onUndo?: (() => void) | undefined;
  /** How long "Saved" shows; 0 says nothing. Defaults to OP_DEFAULTS.savedWordMs. */
  savedMs?: number | undefined;
  className?: string | undefined;
};

/**
 * The status an inline change carries beside its value: "Saving…" with the notch, a quiet "Saved" that leaves on
 * its own, or the reason it failed on its own line with Retry and Undo. Nothing when idle. Words only: the value
 * itself is the proof, so success never turns green. Not a live region; the operation announces.
 */
function OpStatus({ op, pending = "Saving…", done = "Saved", failed = "Not saved", was, onUndo, savedMs = OP_DEFAULTS.savedWordMs, className }: OpStatusProps) {
  /* the answer whose "Saved" has had its moment */
  const [quiet, setQuiet] = React.useState<number | undefined>(undefined);
  const fresh = op.state === "done" && savedMs > 0 && quiet !== op.at;
  React.useEffect(() => {
    if (!fresh) return;
    const t = setTimeout(() => setQuiet(op.at), savedMs);
    return () => clearTimeout(t);
  }, [fresh, op.at, savedMs]);
  if (op.state === "pending")
    return (
      <span data-slot="operation" data-state="pending" className={cn("inline-flex items-center gap-1.5 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]", className)}>
        <Spinner size={12} />
        {op.slow ? stillWord(pending) : pending}
      </span>
    );
  if (op.state === "done" && fresh)
    return (
      <span data-slot="operation" data-state="done" className={cn("text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)] [animation:ns-fade_var(--ns-time-color)_var(--ns-ease)]", className)}>
        {done}
      </span>
    );
  if (op.state === "failed" && op.error)
    return (
      <div data-slot="operation" data-state="failed" className={cn("flex basis-full flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-[length:var(--ns-text-meta)] text-[var(--ns-bad-ink)]", className)}>
        <span className="min-w-0">
          {failed}: {lower(op.error.message)}
          {was ? <> {was}</> : null}
        </span>
        {(op.canRetry || onUndo) && (
          <span className="flex gap-1">
            {op.canRetry && (
              <Button size="sm" variant="ghost" className="text-[var(--ns-ink)]" onClick={() => void op.retry()}>
                Retry
              </Button>
            )}
            {onUndo && (
              <Button size="sm" variant="ghost" onClick={onUndo}>
                Undo
              </Button>
            )}
          </span>
        )}
      </div>
    );
  /* at rest it says nothing, and takes no room */
  return <span data-slot="operation" data-state={op.state} hidden className={className} />;
}

/* "Saving…" reads on as "Still saving…" */
const stillWord = (w: string) => `Still ${w[0]!.toLowerCase()}${w.slice(1)}`;

/* "The connection dropped." reads on from "Not saved: the connection dropped." */
const lower = (m: string) => (/^[A-Z][a-z]/.test(m) && !/^(I|I'm|You're)\b/.test(m) ? m[0]!.toLowerCase() + m.slice(1) : m);

type OpBannerProps = {
  op: OpLike;
  /** The fact first: "Couldn't create the deal". */
  title: React.ReactNode;
  /** Back to the form: focuses the field to change. */
  onEdit?: (() => void) | undefined;
  className?: string | undefined;
};

/**
 * The form-level failure at the top of a dialog or sheet: the fact, the reason, then Retry (when it can help and
 * the footer verb was kept, OP_DEFAULTS.failedVerb) and Edit. A validation error that names a field says its line
 * on that field instead, so this stays away.
 */
function OpBanner({ op, title, onEdit, className }: OpBannerProps) {
  if (op.state !== "failed" || !op.error || (op.error.code === "validation" && op.error.field)) return null;
  const retry = op.canRetry && OP_DEFAULTS.failedVerb === "keep";
  return (
    <Banner
      tone="bad"
      title={title}
      className={className}
      actions={
        retry || onEdit ? (
          <>
            {retry && (
              <Button size="sm" onClick={() => void op.retry()}>
                <RotateCcw />
                Retry
              </Button>
            )}
            {onEdit && (
              <Button size="sm" variant="ghost" onClick={onEdit}>
                Edit
              </Button>
            )}
          </>
        ) : undefined
      }
    >
      {op.error.message}
    </Banner>
  );
}

/**
 * A form's one verb, wired to its operation for Dialog's `verb`: busy while pending (fields lock, nothing clears),
 * and, when OP_DEFAULTS.failedVerb is "retry", "Try again" after a retryable failure.
 */
function opVerb(op: OpLike, label: string, busyLabel: string, onClick: () => void) {
  const again = OP_DEFAULTS.failedVerb === "retry" && op.canRetry;
  return { label: again ? "Try again" : label, busyLabel, busy: op.state === "pending", onClick: again ? () => void op.retry() : onClick };
}

/** Marks a row that just arrived (the signal mark) or came back refused (one bad pulse); the mark clears itself. */
function markRow(el: Element | null | undefined, how: "landed" | "refused") {
  if (!el) return;
  const attr = how === "landed" ? "data-ns-landed" : "data-ns-refused";
  el.setAttribute(attr, "");
  el.addEventListener("animationend", () => el.removeAttribute(attr), { once: true });
}

/** A new record landed: its toast offers Open (unless OP_DEFAULTS.createdToast is off). */
function notifyCreated({ title, detail, onOpen }: { title: string; detail?: string | undefined; onOpen?: (() => void) | undefined }) {
  if (!OP_DEFAULTS.createdToast) return;
  notify({ title, detail, action: onOpen ? { label: "Open", onClick: onOpen } : undefined });
}

export { markRow, notifyCreated, OpBanner, opVerb, OpStatus, useOperation, type OpBannerProps, type OpStatusProps };
export { opError, OpFailure, opErrorLine, toOpError, type OpError, type OpErrorCode } from "@/lib/notchset/op-error";
```

### lib/notchset/op-error.ts

```tsx
// Notchset operation 2.0.0 · https://notchset.dev/changelog
/*
 * The one shape a failed operation takes (installs to lib/notchset/op-error.ts with any piece that reads it).
 *
 * An adapter throws whatever its transport throws (a fetch TypeError, a Response, its own { code, message }); the
 * UI only ever reads an OpError, made by toOpError. The code decides what the person is offered: a retryable error
 * keeps Retry, a validation error with a field puts its line on that field, and a permission or conflict error
 * drops Retry for Edit or Reload, since trying the same thing again can't help.
 */

export type OpErrorCode = "network" | "timeout" | "validation" | "permission" | "conflict" | "unavailable" | "unknown";

export type OpError = {
  code: OpErrorCode;
  /** One plain sentence the person reads: what went wrong, and that their work is kept when it is. */
  message: string;
  /** The field a validation error belongs to: its line goes under that field, not in the banner. */
  field?: string | undefined;
  /** False when the same request can't succeed again (validation, permission, conflict): Retry is not offered. */
  retryable: boolean;
  /** The HTTP status, when there was one. */
  status?: number | undefined;
  /** A request id or trace to quote to support; printed in the diagnostic line. */
  detail?: string | undefined;
};

const CODES: readonly OpErrorCode[] = ["network", "timeout", "validation", "permission", "conflict", "unavailable", "unknown"];

/** The words each code says when the backend gave none. */
export const OP_ERROR_WORDS: Record<OpErrorCode, string> = {
  network: "The connection dropped. Everything you typed is still here.",
  timeout: "The server didn't answer in time. Everything you typed is still here.",
  validation: "Something here needs a change before it can be saved.",
  permission: "Your role can't make this change.",
  conflict: "Someone changed this since you opened it. Reload to see their version.",
  unavailable: "The server is unavailable right now. Nothing you saved is lost.",
  unknown: "Something went wrong on our side. Nothing you saved is lost.",
};

const RETRYABLE: Record<OpErrorCode, boolean> = { network: true, timeout: true, validation: false, permission: false, conflict: false, unavailable: true, unknown: true };

/** An OpError with the code's own words and retry rule, overridden by what you pass. */
export function opError(code: OpErrorCode, message?: string, extra?: Partial<Omit<OpError, "code" | "message">>): OpError {
  return { code, message: message || OP_ERROR_WORDS[code], retryable: RETRYABLE[code], ...extra };
}

/** Thrown by an adapter to fail with a typed error; toOpError reads it back unchanged. */
export class OpFailure extends Error {
  readonly op: OpError;
  constructor(op: OpError) {
    super(op.message);
    this.name = "OpFailure";
    this.op = op;
  }
}

const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;

/** The code an HTTP status means. */
export function codeOfStatus(status: number): OpErrorCode {
  if (status === 400 || status === 422) return "validation";
  if (status === 401 || status === 403) return "permission";
  if (status === 409 || status === 412) return "conflict";
  if (status === 408 || status === 504) return "timeout";
  if (status === 429 || status === 502 || status === 503) return "unavailable";
  return "unknown";
}

/**
 * Anything an adapter or fetch threw, as an OpError: an OpFailure or an OpError-shaped object as it is; an abort
 * from a timeout as timeout; a failed fetch (or being offline) as network; a Response or an error carrying a
 * status by that status; an adapter's own { code, message, field } with an unknown code as unknown, keeping its
 * words.
 */
export function toOpError(e: unknown): OpError {
  if (e instanceof OpFailure) return e.op;
  if (isRecord(e) && typeof e.code === "string" && (CODES as readonly string[]).includes(e.code) && typeof e.message === "string") {
    const code = e.code as OpErrorCode;
    return {
      ...opError(code, e.message),
      ...(typeof e.retryable === "boolean" ? { retryable: e.retryable } : {}),
      ...(typeof e.field === "string" ? { field: e.field } : {}),
      ...(typeof e.status === "number" ? { status: e.status } : {}),
      ...(typeof e.detail === "string" ? { detail: e.detail } : {}),
    };
  }
  const name = isRecord(e) && typeof e.name === "string" ? e.name : "";
  if (name === "TimeoutError") return opError("timeout");
  if (name === "AbortError") return opError("timeout", "The request was stopped before it finished. Everything you typed is still here.");
  const status = isRecord(e) && typeof e.status === "number" && e.status >= 400 ? e.status : undefined;
  if (status !== undefined) {
    const code = codeOfStatus(status);
    const said = isRecord(e) && typeof e.message === "string" && e.message && !/^(request failed|http )/i.test(e.message) ? e.message : undefined;
    const field = isRecord(e) && typeof e.field === "string" ? e.field : undefined;
    const detail = isRecord(e) && typeof e.detail === "string" ? e.detail : isRecord(e) && typeof e.requestId === "string" ? e.requestId : undefined;
    return opError(code, said ?? (status === 401 ? "Your session ended. Sign in again, then try again." : undefined), { status, ...(status === 401 ? { retryable: true } : {}), ...(field ? { field } : {}), ...(detail ? { detail } : {}) });
  }
  if (typeof navigator !== "undefined" && navigator.onLine === false) return opError("network", "You're offline. Everything you typed is still here.");
  if (e instanceof TypeError && /fetch|network|load failed/i.test(e.message)) return opError("network");
  const message = typeof e === "string" ? e : e instanceof Error ? e.message : isRecord(e) && typeof e.message === "string" ? e.message : "";
  const field = isRecord(e) && typeof e.field === "string" ? e.field : undefined;
  return opError(field ? "validation" : "unknown", message || undefined, field ? { field } : undefined);
}

/** The diagnostic line a person can quote to support: "503 · unavailable · 10:42 · req 7f3a91". */
export function opErrorLine(e: OpError, at: Date = new Date()): string {
  const time = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
  return [e.status, e.code, time, e.detail && `req ${e.detail}`].filter(Boolean).join(" · ");
}
```

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