Agent docs

2 · Buttons and actions

The one clickable-action control and its color, weight, icon, and wording rules.

The one clickable-action control and the rules governing its color, weight, icon, wording, and disabled behavior. Kit source: packages/ui/src/button. Evidence registers: kit-inventory.md, jobs-parts, review-files-contentgen, and admin-tasks-auth inventories under docs/evidence/20260807-0230-speedway-design-language/ in this repo.

Audience: an agent (or person) building or reviewing buttons and actions.

§A Principles and mental models#

1. One component, five variants, one hover vocabulary. variant: solid | outline | ghost | link | text. size: xs-xl. color: the 8-way SemanticColor. shade on quiet variants tints hover with the button's OWN color via the shared SURFACE table, "one hover vocabulary across every quiet interactive surface" (internal/surface.ts). The quiet preset (text + shade) exists because it was "the app's most-respelled combo (28 of 29 text-variant uses)".

2. Color is semantics, not taste.

  • success = creation and confirmation: every "New job", "Create job", "Add member", "Add loadsheet", "Save mapping" submit (observed as a house rule across admin, account, and loadsheet forms).
  • primary = page-level forward actions and saves of existing config: Sign in, Save changes, Download, module Run buttons.
  • error = destructive: Delete presets, template Delete. Escalation in a toolbar reads outline → ghost → ghost+error as destructiveness rises (speedway setup.tsx:912-951).
  • warning = undo-flavored actions (audit-log Revert).
  • No color at all marks a dead-end/exit action (auth no-access SignOut).

3. Weight placement rules. Toolbar creation actions use variant="text" shade color="primary" Icon="Plus" (the admin house trio). S2 config pages pin Cancel (outline) + "Save & start" (success) top-right. Modal footers pair ghost Cancel with a small primary submit. The kit ModalFooter keeps both deliberately quiet. fullWidth belongs to signed-out auth screens only. iconRight (trailing arrow) signals forward motion (Sign in) and "go to the friendlier surface" links.

4. A button that cannot act is hidden or explains itself, never silently disabled.

  • Hidden when the action is impossible (Download hidden at zero parts. RunButton returns null while in flight, "the stage badge's pulsing dot is the working cue, no second spinner").
  • bricked + Tooltip (the useDisabledReason contract) or a plain-text disabledHint beside the button (NewJobForm's "Pick a loadsheet for each file"; a second clean instance at ScrapeTargetPicker.tsx:182-193, the URL-cap hint) when the user can fix the condition.

5. Pending state lives on the button, never the status badge. loading shows the spinner and blocks a second click. A requested-but-unhonored cancel renders as a disabled loading button labeled "Cancelling" while the badge keeps the plain run status (owner ruling D2, JobActions.tsx:44). promisify drives this automatically from an async onClick. IconOnClick gives a 1s checkmark acknowledgement for fire-and-forget actions.

6. Mutations settle into toasts. The universal write pattern: fetcher submit → useEffect on idle → pushAlert.success/.error. Never a synchronous alert. Never toast logic owned by a component that unmounts on success (the bulk-bar lesson, doc 10).

7. One state per control. The control shows the flip. A toggle is ONE button whose color, icon, and label flip together (WorkflowToggle: success "Switch off" vs warning "Switch on"). The reasoning: "two side-by-side buttons read as a choice with no visible current state" (owner gripe C#2).

8. Icons come from the registry. Never import react-icons at a callsite. Go through IconFor/RenderIcon (about 70 curated concepts, minimum 16px render, dev-visible fallback for unknown keys). Every Button is Tooltip-wrapped unconditionally. The tooltip degrades to a passthrough when empty.

9. An interactive thing is a real control, and it has a name. Added 2026-08-13 from the correction corpus, where three separate findings across both apps were the same defect wearing different clothes. A div or span given an onClick is not a control: it has no role, no tab stop, and no keyboard path, so it exists only for a mouse. If a thing responds to a click, it is a button or a Link, or it carries the role, the tab index and the key handling itself. Second half, and the more commonly missed: a control with no text needs an accessible name. The kit's own Button now derives one, in the order explicit aria-label, then the string tooltip, then the icon key spaced into words (commit a6f6376, shipped in 0.0.24, documented at button.md). So a kit Button is covered. What the kit cannot reach is the layer beneath it: a raw <button> or a <div onClick> written by hand gets nothing, and wrapping one in a kit Tooltip does not help, because Tooltip wires no aria-label, no aria-describedby and no role. Both apps still do exactly this, walmart at FacetToolbar.tsx:46-47 and Jobs.tsx:131-132. A caption rendered NEAR a control is not its name either. The kit's own Input renders its top caption outside the <label> that wraps the field, so the visible caption and the control are not associated, and neither a screen reader nor a getByLabel query can connect them. That one is a kit defect rather than a consumer defect, tracked for the next kit version. The general rule holds regardless of layer: reachable, nameable, operable by keyboard. This principle is deliberately separate from the contrast measurement discipline in 00 §6, which is about whether text can be READ. This is about whether a control can be USED, and the canon covered the first from its first day while saying nothing about the second. Note also that "a kit Button is covered" holds only for an app whose pin reaches 0.0.24. Walmart's does; speedway's declared ^0.0.14 does not, and speedway is the app this canon was derived from. See the consumer-pin note in 00-overview.md.

10. The toolbar right button declares itself. Added 2026-08-17 from the owner's second look at the playground (doc 59 G1). A card's, a section's or a page title's right-hand action is never the kit's zero: bare variant="outline" color="neutral" with no icon, no shade and no tone reads as a placeholder beside the title it belongs to. Never ship that combination as a toolbar action. The floor is shade; an icon is strongly encouraged; an action that navigates is variant="text" with Icon="Right" iconRight, and an action with a meaning takes its tone. It also lives in the toolbar slot, never in a row of its own under the title (the "Swap sides" button on apps/playground/src/app/patterns/shape-matched-skeleton/ was the case). Two companions ride with it: a Button never underlines, at rest or on hover, in any variant (hover feedback is the shade wash; underline belongs to prose links in Markdown), and a text button's count or identifier (the "See all 67 docs" shape) inherits the button's colour and size with no separate opacity (doc 60 §4, §5, §6; button.md Banned).

§B Presets#

Each row names the preset, what it bundles, the situation it is for, and the showcase example that renders it, so this table is the layer between a principle above and button.md below.

PresetBundleUseShowcase
newPlus + "New"creation triggersthe presets example (apps/playground/src/app/components/button/examples/presets.tsx:7)
saveSave + successconfirm/create submitssame example
deleteTrash + errordestructive, pair with ConfirmModalsame example; gated in place at apps/playground/src/app/patterns/confirm-and-report/example.tsx:33
backLeftArrow + outlinedetail-page returnssame example
iconempty contenticon-only actions, always tooltippedsame example
quiettext + shadethe default quiet actionsame example
splitdropdown propprimary action + caret menuapps/playground/src/app/components/button/examples/split-button.tsx:5
row kebabRowMenucollapse per-row actions into one menuthe data-table gallery's RowMenu card (apps/playground/src/app/components/data-table/gallery.tsx:339)
row remove behind a gatethe delete preset + ConfirmModal + pushAlert.promisea destructive row action: the dialog closes on confirm and the toast reports the outcomeapps/playground/src/app/patterns/confirm-and-report/example.tsx:33
bulk action carrying its countone warning-coloured Button whose label carries the live count, in a bar under the tableselection-driven toolbars, once rows are pickedapps/playground/src/app/patterns/bulk-selection-bar/example.tsx:20
exitno colour at all, a plain text row (§A2 rule 2)sign out, leave, and other dead-end actionsthe account menu's sign-out row (apps/playground/src/app/patterns/account-menu/example.tsx:49)

§C Use cases through Speedway#

  • Jobs list: PageTitle action is the NewJobMenu dropdown (success text-variant trigger). Per-row actions collapsed into one RowMenu (Download / Edit name / Review / staff Delete) after two icon buttons proved unreadable without tooltips (JobsTable.tsx:789-825; the collapse rationale lives in the export docstring at :250-260).
  • Parts tables: one always-visible Eye icon button (size="xs" variant="ghost") as the single entry to part detail. Richer actions live in rowMenu on the standalone page only.
  • Review queue: Approve (text success Check) only when a candidate exists. Ignore stays muted text. Fix carries the rotating caret inside itself so the table needs no indicator column.
  • Run/Job actions: Cancel/Resume as small fetcher-form buttons with loading. Rerun is gated by confirm.
  • Admin: four-icon ghost row (Edit / Lock / Enable-Disable flip / Trash) identical pixel-wise between staff and customer surfaces while encoding different permission gates (staff acts on everyone but self, and an owner cannot touch another owner).
  • Auth: all CTAs primary + fullWidth. Login's arrow is the scope's only iconRight.
  • Known deviations to keep bounded: raw daisyUI btn classes inside dense editable grids (contentgen setup, loadsheet alias rows) and the kit ModalFooter's own btn classes (a documented swap-later TODO).
@versable-git/ui · reference, canon, and method, read in place