Resolved: light

Workflow

The chrome of an edit/detail screen, composed on the Surface primitive. TitleBar is the header strip (title + breadcrumbs slot + status badge + primary/secondary actions, with the secondary actions folding into a “⋮” overflow on mobile); Breadcrumbs is the hierarchy trail; SaveBar is the sticky bottom bar that rises when the form is dirty; and useUnsavedChanges guards against losing edits on the way out. Everything is presentational — props + callbacks, no server actions. Flip the theme in the top bar to confirm every surface re-skins from the tokens.

TitleBar
Surface header strip. title (+ as: h1|h2|h3), breadcrumbs slot, status badge slot, primaryAction (always visible) + secondaryActions[] (inline on sm+, overflow Menu on mobile), free-form actions slot. Client (the overflow is a Base UI Menu).
Breadcrumbs
items: { label, href? }[] → <nav aria-label='Breadcrumb'> / <ol>; the last (or any href-less) crumb is a non-link aria-current='page'. separator (default chevron), maxItems (collapse the middle to '…'). Server-safe.
SaveBar
Sticky bottom Surface (overlay elevation). visible (drive from dirty), onSave / onDiscard, saving (busy → Save spins, bar locks), saveDisabled, message, children slot. Collapses to zero height when hidden (no reserved gap); reduced-motion aware. Server-safe.
useUnsavedChanges(dirty)
Route-leave guard. Registers beforeunload only while dirty (hard nav), and returns { confirmLeave, onNavigate } for soft nav — onNavigate drops onto <Link onNavigate=…>, confirmLeave() gates a programmatic router.push. Options: { message, enabled }.

The edit screen

All four pieces wired together exactly as a real edit page would: a TitleBar (breadcrumbs + live status badge + a Save primary and Preview/Duplicate/Archive/Delete secondaries that collapse to a “⋮” on a narrow screen) over a form, with the SaveBar rising from the bottom the moment any field changes. useUnsavedChanges is active throughout — dirty the form and try to reload the tab. Narrow your window (or the frame) to watch the secondary actions fold into the overflow menu.

Spring Adoption Drive

Saved

Edit any field to dirty the form and raise the SaveBar.

Full edit screen — TitleBar (breadcrumbs + status + actions, overflow on mobile) over a form, with the SaveBar rising from the bottom when dirty

TitleBar

The header strip in isolation: with a status badge + a single secondary, with an icon primary + a full secondary set (resize down to see the overflow), in a raised surface-2 tone, and bare (title only). The action cluster is a labelled role='group'; the inline desktop buttons and the mobile overflow menu render the same action descriptors, so nothing is lost on small screens.

New volunteer

Draft

Phonebank script

Published

Read-only record (no actions, no status)

TitleBar — status badge, icon actions, surface-2 tone, and title-only
TitleBarAction
{ key, label, onClick? | href?, icon?, variant?, disabled?, loading? }. href renders a LinkButton (no spinner); otherwise a Button. The same descriptor drives the inline button and its overflow-menu row.
responsive overflow
secondaryActions render inline (hidden below sm), and a sm:hidden Menu holds the identical list behind a labelled ⋮ trigger. The Base UI Menu brings roving-tabindex, type-ahead, arrow keys and Esc for free.
slots
breadcrumbs (above the title), status (beside it), actions (free-form, after the structured actions). tone / padding / elevation / bordered pass straight to Surface.

Breadcrumbs

The hierarchy trail (usually dropped into TitleBar's breadcrumbs slot). A standard four-level trail, a six-level trail collapsed to '…' via maxItems={4}, and a trail with a custom “/” separator. The current page is rendered as non-link text with aria-current='page'; separators are aria-hidden.

Breadcrumbs — standard, collapsed (maxItems), and custom separator
items
{ label, href? }[]. A crumb with no href — and always the last one — renders as plain text with aria-current='page'. Links are plain <a> (router-agnostic); wrap a label in your own <Link> for prefetch.
maxItems
Collapse the middle to a single '…' once there are more than maxItems crumbs, always keeping the first and the tail. Omit to never collapse.
separator
Any node (aria-hidden), default a chevron glyph. ariaLabel overrides the nav landmark name ('Breadcrumb').

SaveBar

The sticky save/discard bar. Toggle its visibility to see it collapse to zero height (no reserved gap) and animate back open, and toggle the busy state to see Save turn into a spinner while the whole bar locks. In a real edit page you'd drive visible from your dirty flag (see the edit-screen demo above).

The bar collapses to zero height when hidden (no reserved gap) and animates open — try “Hide bar”. Reduced-motion users get an instant toggle.

3 unsaved changes.

SaveBar — visibility + busy state (sticky inside its scroll frame)
visible
Show/hide (drive from dirty). Hidden = a CSS grid-rows collapse to 0fr, so it leaves no blank strip; motion-reduce:transition-none honors reduced motion, and the collapsed bar is aria-hidden + out of the tab order.
saving
Busy state: the Save button shows its loading spinner, Discard + Save disable, and the region is aria-busy. saveDisabled disables Save independently (e.g. an invalid form).
slots & copy
message (left status copy), children (extra controls left of Discard/Save), saveLabel / discardLabel, saveVariant, tone. onDiscard is omitted → no Discard button.

MergeWorkbench

The generic 'two records into one' reconciler (src/components/ds/MergeWorkbench): Record A · Record B · a live RESULT preview. Each differing field is a choose-one ToggleButton pair; conflicts (both sides non-empty and different) are highlighted with a one-line 'why', read-only provenance is shown but not selectable, and identical fields collapse away. It's purely presentational + controlled — the smart per-field defaults (gap-fill, prefer the newer correct contact value) live in the pure src/lib/merge-resolve. Powers the non-destructive /admin/duplicates merge.

Differing (3)

#101 Jane Qnewer
#88 Jane Qolder
Result
Email
jane@gmail.com

Kept the address with a valid (non-typo) domain

Phone
(555) 010-2233

Filled from the older record (newer was empty)

Notes
Available weekends

Kept the newer value

Provenance

Submitted
Jun 20, 2026
Jan 4, 2026
Data Source
form
spreadsheet
Identical (2) — click to expand
Full Name
Jane Quill
City
Madison
MergeWorkbench — gap-fill, a typo-domain email conflict, and provenance
columns / fields
columns: { key, label, badge? }[] (the records). fields: { key, label, values[] (one per column), conflict?, hint?, readOnly? }[]. Differing fields are selectable; readOnly fields render as provenance; all-equal fields collapse into 'Identical (n)'.
selection / onSelect
Controlled: selection maps fieldKey -> chosen column index; onSelect(fieldKey, columnIndex) updates it. The caller seeds the defaults (e.g. from src/lib/merge-resolve resolveRecords) and owns the merge action.
RESULT preview
Each differing row shows the currently-chosen value in an accent RESULT cell — the live 'record you'll get'. Conflicts get a warn-tinted row + the hint text; gap-fills surface their 'filled from the older record' hint.

useUnsavedChanges

The route-leave guard. Arm it (the toggle stands in for a real dirty form), then exercise both paths: confirmLeave() gates a programmatic leave (returns true/false), and the guarded link wires guard.onNavigate onto a next/link so a soft navigation confirms first. While armed, reloading or closing the tab also triggers the browser's native beforeunload prompt.

Guard idle
Guarded link (onNavigate)

While the guard is armed, reloading or closing this tab also prompts (the native beforeunload dialog). The custom message only shows on the in-app confirms — browsers force their own text on the native one.

useUnsavedChanges — beforeunload (hard nav) + onNavigate / confirmLeave (soft nav)
approach
Next 16's App Router has no global route-change blocker (Pages-router router.events is gone). So the hook owns the one guard it can — a beforeunload listener attached only while dirty (hard navigations: reload, tab close, external/typed URLs) — and hands back helpers for in-app (soft) navigation.
onNavigate
Matches next/link's OnNavigateEventHandler — drop it onto <Link onNavigate={guard.onNavigate}>. It confirms when dirty and calls e.preventDefault() to cancel the client-side navigation if the user declines. For app-wide blocking, lift dirty into a context and feed every link's onNavigate from it.
confirmLeave
Call before an imperative leave: if (guard.confirmLeave()) router.push('/elsewhere'). Returns true when it's safe (not dirty, or the user confirmed). Options: { message } (the soft-nav prompt copy), { enabled } (master off-switch, e.g. right after a save).