Agent docs

10 · Overlays

Peek panels, modals, dropdowns, and toasts, and the decision tree between them.

Peek panels, modals, dropdowns, and toasts, and the decision tree between them. Kit sources: packages/ui/src/modal, dropdown, side-panel, toast. Evidence registers under docs/evidence/20260807-0230-speedway-design-language/ in this repo.

Audience: an agent (or person) choosing or building a peek, modal, or toast.

Principles 9 to 13 and the walmart cases in §C were promoted 2026-08-09 from the owner-approved modal-conventions spec (docs/evidence/20260808-modal-conventions/SPEC.md), which carries the measurements and the per-surface evidence behind them. Owner ruling D1 directed the spec to extend this doc rather than sit beside it.

Travels with: 06 (the row that opened it, and the single-entry rule), 04 (its loading and empty states match the page's), 02 (its actions and their toasts), 03 (status inside it). Request-shaped bundle: AP-11.

§A Principles and mental models#

1. The decision tree: peek vs modal vs page.

  • Peek panel when the surrounding context must stay visible (a run's log beside a part detail). It is an in-flow column, not an overlay.
  • Modal when the detail IS the task and context can be obscured (part preview from any parts list. The documented migration: "the Eye icon is the one way into a part's detail now... The row-click side panel is gone").
  • Full page only when the surface is a destination in its own right. Speedway retired the part page entirely ("it duplicated the parts table's preview dialog"). Diagnostic pages (part-debug) are the exception.

Deviation clause (owner ruling D1, 2026-08-09). This tree is the default, and a surface may break it only "IF the rule-breakage leads to a better and more consistent UX, with good reasoning". Deviation is therefore legitimate and never free: the surface that deviates records its reason in its own doc, and the reason names the UX the break buys. The ruling retired per-surface discretion exercised silently.

2. One drawer, claimed. "The app's ONE right-hand drawer. Two sidebars can never sit side by side." A claim/release/eviction slot system: a peek claims the slot, Activity yields, release restores. Peeks persist width via storageKey. SidePanel clamps resize 240-640px and defers its ESC to any open native dialog.

3. Modals are store-driven, string-identified, and local. Real <dialog> under the hood (browser backdrop, top-layer, ESC). Ids are app-owned strings ("a shared enum would force every app's modals into one namespace"). modalKey targets one instance among many (per-row edit). Opening is state, not navigation: "no loader re-run" (the F5.1 pattern). A URL-driven open (?new=1) pairs with an effect closing any modal the param does not vouch for. Modals mount at the callsite, parked at the bottom of the page's JSX, never centrally.

4. Modal chrome is composed, and quiet. ModalTitle (sticky; closes via form method="dialog", zero handler code; module glyphs in identity ink spelled as literal classes for the Tailwind scanner) + ModalFooter (config-driven button pair, both ghost, "primary action is text-emphasis only"). Sizes are fixed presets: xs 475 / sm 700 / md 1000 / lg 1400 / xl 100%, all capped 90vw. Pick the smallest that fits the job ("this form wants a dialog": sm instead of the md workbench).

5. Confirm is componentized, with one documented exception class. ConfirmModal for every destructive/state-changing action (delete preset for removals; beforeCloseCallback returning {abort:true} is the unsaved-changes guard). Modals close on success EXCEPT when a one-time secret must stay copyable ("stays open after a submit so a surfaced temp password is copyable"). Raw confirm call sites are a flagged inconsistency, not a pattern. Corrected 2026-08-13: this doc previously named one, an admin loadsheets delete. Speedway carries four, and the loadsheets case is one of them: a rerun and resume pair at app/routes/workspaces/jobs/job.tsx:292 and :326, a template delete at setup.tsx:943, and the loadsheets delete at app/routes/admin/loadsheets.tsx:247. Walmart carries none. An earlier version of this correction retired the loadsheets case as nonexistent, because it greps as a bare confirm( rather than window.confirm; search \bconfirm\( or you will undercount the same way twice.

6. Perceived speed rules for overlays. Open instantly on click and load inside (the file preview "stays mounted through the async fetch... preview clicks felt dead"). Share payload caches across surfaces so "either surface reopens instantly". A stale reference toasts and closes rather than sticking a spinner.

7. Dropdown is positioning, not a menu. Content is caller JSX. Select/RowMenu/WorkspaceSwitcher/split-Button compose on it. Position and exit animation live on separate nested divs (inline transform beats classes). Open/close uses a two-state trail so the exit animation plays. matchTriggerWidth for menus under full-width controls. The one sanctioned hand-rolled listbox exists because a native select "fires nothing on a same-value pick". A reselectable-select primitive is a known kit gap.

8. Toasts: one frozen API, owned by survivors. Everything routes through pushAlert (variants, .promise, sticky duration: -1 spinner toasts manually cleared, same-message auto-dedupe by hashed id, copy default-on for errors). The ownership lesson: toast state must live in a component that SURVIVES the success ("success empties the selection, which unmounts the bar, feedback owned by the bar died with it"). Bulk outcomes keep to one line, naming the first failure and counting the rest. Alert is for in-flow notes. Toast is for transient events.

9. Modal title anatomy: glyph, then primary over secondary. An identity glyph, then a column of a primary line above a secondary line. The primary line is verb, noun, identifier. The secondary is the human-readable name. The identifier renders in mono per 01 §A, and IS a copy button, raising a toast that names what was copied. Structured props, never hand-built JSX: ModalTitle takes identifier, subtitle and onIdentifierCopied as of kit 0.0.23. Corrected 2026-08-13: this doc previously reported the walmart preview modal as already collapsed into those props, 28 lines becoming 5. It is not. PartPreviewModal.tsx:830-849 still passes a JSX title containing its own CopyButton, and passes only subtitle of the three. So the anatomy is available and unadopted at the reference surface, which is the target rather than the shipped state. See packages/ui/docs/modal.md. A consumer reimplementing a kit primitive is the defect class this anatomy exists to prevent. Literal copy stays the surface's own: "View Part" is walmart's string, not the anatomy.

Loading. RULED, not yet built (owner ruling 2026-08-08; tracking: walmart PartPreviewModal takes only partId, so the identifier still arrives with the fetch). A modal opened from a row should inherit what the row already knows and thread the identifier in rather than waiting for it. The fixed half of a title is known at build time, so 04 §A3 already forbids boning it, and the reference surface satisfies that half. What is missing is the positive half: the caller passing identity. It is a props change, not a render change.

10. Horizontal inset has exactly one owner. The shell owns it. The body carries none of its own. This is the modal case of the general rule at 05 §A4, whose first instance is the full-bleed table: one owner, and content that needs the edge cancels rather than re-adds. Measured, not inferred: modal-box pads 24px left and right, and the kit strips top padding (modal.tsx:48) because ModalTitle owns its own. The bug that taught this is preserved as a comment in walmart's preview modal, "doubling it pushed the body in twice". Two sibling modals carried the doubled 48px for weeks because both rendered perfectly well, just inset twice as far as the title above them, and no typecheck or review surfaces that. Both were swept 2026-08-08, the same day the preview modal recorded the rule.

11. Empty and error states inside an overlay match the page's. An empty region is PageInfo size="sm", and its tone carries meaning rather than decoration: muted when the absence is neutral, success when the absence is the good outcome (an errors tab reporting no errors is an achievement, not a void). A modal that reports "no errors" in the same grey as "no images" says two different things in one voice. A failed load renders the structured error block, never a bare line of red text. The stage-logs modal's bare text-error span was brought to PageError for exactly this reason.

12. Copy affordances attach to the data plane, never the control plane. Owner ruling, 2026-08-08. Copy belongs on values naming the thing (identifiers, attributes, generated content, error messages), never on values reporting how the work is going (status pills, coverage chips, completion ratios). The test is mechanical rather than tasteful: if it renders in the status vocabulary, it must not carry copy. That is the canon's identity-is-not-status model (03 §A) applied to affordances instead of colour.

13. The body scrolls, the shell does not. Promote the behavior, not the number. A cap tuned to one modal's tab content (max-h-[60vh]) is that surface's tuning. Each consumer states its own, or better, the height comes from the shell.

14. A floating bar can go translucent, opt-in. The same recipe as 08-navigation-and-shell.md §B's translucent bar (surface at /80, backdrop-blur, a border that appears only once content is actually sliding beneath the bar) applies to a sticky modal header or footer, or a wizard's footer, floating over a scrolling body. The gate is the body's own scroll position rather than the page's: a header picks up the treatment once the body has scrolled away from its top edge, a footer once there is more content below the visible bottom. It is opt-in for the same reason doc 08 gives, blur costs legibility over dense content, so a form-heavy modal body keeps its header and footer opaque by default. A modal footer over a body short enough that nothing ever scrolls beneath it needs none of this: the gate never fires, so the bar sits in its resting state and the opt-in buys nothing. Not built: ModalTitle and ModalFooter render bg-base-100 unconditionally today (modal-elements.tsx:33, modal-elements.tsx:136), so this is the recorded recipe for the day a caller wants it, not a shipped prop. See packages/ui/docs/modal.md.

§B Presets#

SituationPreset
Detail beside contextPeekPanel claim, persisted width, skeletons shaped like destination
Detail as taskstore-driven Modal, smallest size that fits
Destructive gateConfirmModal (+ delete preset)
Unsaved-changes guardbeforeCloseCallback abort
Secret handoffmodal stays open + select-all-on-focus readonly Input
Per-row menuRowMenu (propagation-stopped both ways)
Async outcomepushAlert on fetcher settle; .promise for tracked jobs
Record modal headerModalTitle with Icon + title + identifier + subtitle; onIdentifierCopied toasts
Modal body insetnone; the shell already pads 24px
Nothing-here regionPageInfo size="sm", tone muted; tone success when the absence is the win
Failed load in a modalPageError, titled for the thing that failed
Floating header/footer, opt-inTranslucent recipe from 08 §B, gated on the body's own scroll instead of the page's; not built into ModalTitle/ModalFooter today

§C Use cases through the apps#

Speedway.

  • Run page: peek panel for outputs (context visible). Parts lists: modal only. Part page: retired.
  • Modal content adapts to knowledge: per-module tabs when the caller knows the chain, generic four-tab set otherwise.
  • New-job modal at sm, URL-driven. File preview modal with three open paths (local / by-id / by-name) and instant-open.
  • Advanced settings uses a native <details> collapse, deliberately not a modal, so collapsed fields still submit with the form.

walmart-mvp.

  • Part preview: the worked record-modal case. Type glyph, "View Part" over the product name with the part number as a copy button, chip tab bar, body owning no horizontal inset, content region scrolling internally.
  • Stage logs: deliberately tabless. Twenty capped lines carrying a level and a message have nothing to tab between, and the surface states its cap in a static scope line rather than paginating. Tabs are the preview modal's taxonomy, not the modal idiom.
  • Import duplicate dialog: a decision gate rather than a detail view, and every dismissal path (backdrop, Escape, the X, Cancel) discards the already-created job. A confirm-shaped modal whose close IS the destructive branch documents that in its surface doc.
  • Empty tones in practice: the images tab is muted, the errors tab is success, in the same modal.
@versable-git/ui · reference, canon, and method, read in place