Components
Try the buttons, fields, and other reusable components. Hover and focus them to explore their states, then check the usage notes, dimensions, and tokens for each example.
This page uses a saved copy of the design system, last changed on . It may differ from the current design source.
Buttons
Buttonlive
The text action. primary for the one affirmative action, secondary (outlined) for alternates, ghost for low-emphasis/toolbar, danger for destructive, agent (mauve) when an AI agent is the actor. One primary per view.
Disabled — ink, never opacity (2026-09-29)
A disabled control is not faded. Every variant draws the same neutral box (--tasty-button-disabled-bg / -border) and its label and icons take the one disabled ink (--tasty-button-disabled-fg → text-disabled). Accent fills (primary · agent · danger) drop out, so the disabled ink never sits on an accent. Ghost keeps no box. No hover or active overlay; cursor stays default. The same rule runs through IconButton, Input, Select, MultiSelect, Checkbox, Switch, MenuItem and ListCtrl.
Don't multiply a disabled control by 0.5. Its label lands at a different step for every variant, and a faded accent fill still reads as "the primary action".
IconButton
Square, icon-only — toolbars, tab close, sidebar rails. Same heights as Button. ghost by default; active shows the persistent accent selection (e.g. an engaged rail tool).
Badge · Tag · Kbd
Badge
Compact count or status pill. danger by default for unread counts (99+); use dot for a bare presence indicator on a rail icon. Every variant is a bg/fg token pair (--tasty-badge-<variant>-bg/-fg) — a badge never reaches into an accent role directly. primary (blue) = Completion attention, warning (yellow) = NeedsInput attention; when a row shows both, wrap them in BadgeGroup — NeedsInput leads, Completion trails.
Attention kinds and their rank live on Layouts › Attention kinds; this spec only shows the chip. Don't pick warning/primary for a non-attention count — the two colors are read as “blocked” and “finished” everywhere else in the product.
Tag
Small monospace label for surface kinds (terminal, markdown), permissions (fs:read), plugin origins, and IDs. Outlined by default; the agent variant marks agent-owned things.
Kbd
Keyboard shortcuts as keycaps, in settings, menus, and the command palette. Pass an array or a "+"-joined string; it splits and caps each key.
One keycap, no palette variant
The command palette drew its own keycaps — 18 square, 5 side padding, 4 gap, 11px type — against Kbd's 16 / 4 / 3 / 10. That is drift, not a variant: nothing about a palette row asks for a bigger key, and a second size would have to be maintained in two places. The palette converges on Kbd. In a 28px palette row a 16px cap leaves 6px of air above and below and still centres on the row's text baseline, so no row height or alignment changes.
The switch-number overlay keycap already reads the Kbd tokens, so the palette was the last divergent cap. One keycap in the system now.
Glyph sizes are icons, not type
Four values were sitting in the font scale because they were written as font sizes: the toggle check (12), the spinner and the switch-overlay keycap digit (16), and the clipboard image glyph (30). Three of them are glyphs, so they are judged by the icon family and land on it exactly — 12 = --tasty-icon-size-xs, 16 = --tasty-icon-size-md. The clipboard glyph is a content mark and snaps to the sanctioned 28 exception it sits next to, dropping the 30. No font token is created for any of them.
Font sizes and glyph sizes are separate families. A 16px glyph next to 13px text is not a type-scale violation; it is an icon at its own size.
Form controls
Inputfocus me
Single-line field. Use mono for paths, IDs, regex, hex. icon for a leading affordance (search), addon for a trailing unit. invalid turns the border + ring red. readOnly (2026-09-29) is for a value that can't be edited now but is still read: the disabled neutral box, the value in --tasty-input-readonly-fg (text-secondary-raised), focusable, selectable and copyable. disabled means the control itself is unavailable.
Select · Checkbox · Switch
Select = native dropdown, styled to match Input. Checkbox for a standalone boolean in a list of settings (inside MultiSelect it is the option row). Switch for a single boolean setting that applies immediately.
MultiSelect — many values in one controlclick me
The Select sibling (not a variant) for when a control holds more than one value — an OR filter like waiting + ready + running. Closed it is a Select: same 28px height, border, radius, font, chevron, so the two read as one family in a form. Open it is a menu of Checkbox rows that stays open while you toggle — outside click or Esc closes. The trigger never enumerates the selection; it shows a caller-injected 3-branch summary (0 → placeholder tone · N → 3 selected · all → All) as plain text, never a Badge. Checked rows get no background — the checkmark is the only checked signal; backgrounds are reserved for pointer hover and the keyboard ↑↓ active row.
Decisions (implementation spec). Sibling, not a Select variant — the open surface is a different thing (checkbox menu, non-native, stays open), so it earns its own catalog entry; Select is untouched. Open = focus treatment + flipped chevron, no third visual state. Bulk toggle exists but is opt-in (allToggle): one accent action row that reads "Select all" and becomes "Clear all" once everything is on — a checkbox there would need an indeterminate state this system doesn't have. Menu may outgrow the trigger up to 320, because the trigger holds a short summary while the rows hold real labels. Per-option disabled is supported (a status with no runs, a permission-gated option) — row keeps --tasty-state-disabled-opacity and is not togglable. Count is plain text, not a Badge: badges live on tabs and rails, not inside form controls. Zero selected is allowed — for a filter, nothing checked should mean no filter (show all) rather than an empty view; forcing a minimum is consumer policy, not a control rule. Row labels are --tasty-text-primary (never muted) so Latte stays ≥4.5:1 on --tasty-surface-raised. First consumer: the DAG list status filter (ui_kits/terminal/overlays/dag_view.jsx) — 6 statuses, allToggle off.
SearchableSelect — Select with a search field in its open list (2026-10-19)
A Select variant for one value out of a long list the user would rather type into — today the font family rows in Appearance › General and the per-font override. Closed it is a Select: same 28 height, 12 left, 28 chevron room, chevron, borders. Open, the popup is the menu popover with a search field on top, a separator, then the shared MenuItem option rows. Short fixed lists — Tab / Workspace / Category switch modifier, exit behaviour, language — stay a plain Select (no search).
Decisions (§6). 1 · Focus: opening moves keyboard focus into the search field; the field shows it with a --tasty-border-focus edge only — the trigger already draws the open ring, two rings would compete. 2 · Keyboard cursor: yes. ↑ ↓ move the active row over the visible, enabled rows (pinned first), Enter commits and closes, Esc closes and returns focus to the trigger, Tab closes. Focus never leaves the field, so typing keeps working. On open the active row is the selected one; after each keystroke it moves to the first match. A plain Select still has no cursor (native). 3 · Query is cleared on every close; reopening shows the full list with the current value checked. 4 · Search stays inside the list, not a combobox trigger: the trigger keeps the Select shape in every state, and typing never overwrites the visible current value. 5 · Pinned options are supported (pinned): always shown above their own separator, ignore the query. "Default (monospace)" is the way back to the default, so it should not disappear because the user typed "fira". This changes the product's current behaviour (shown only when the query is a substring of "monospace"). 6 · Tokens move to shared names --tasty-select-search-*; --tasty-font-combo-list-max-height and -search-inset stay as aliases (no rename, same values). 7 · Modifier combos are a plain Select (Tab / Workspace / Category switch, drag flip): fixed short lists, no search.
Matching: case-insensitive substring on the label (same as the current font combo). A filtered-out selected value keeps its label in the trigger. Contrast: status rows use --tasty-text-secondary-raised on surface-raised (Mocha text-secondary, Latte n1100 5.17:1). Option rows are the MenuItem rows, unchanged. i18n keys: settings.appearance.font_search_placeholder “Search fonts” / “글꼴 검색” / “フォントを検索” / “Schriftarten durchsuchen” · …font_no_match “No matching fonts” / “일치하는 글꼴 없음” / “一致するフォントがありません” / “Keine passenden Schriftarten” · …font_loading “Loading fonts…” / “글꼴을 불러오는 중…” / “フォントを読み込み中…” / “Schriftarten werden geladen…”. The popup grows up to 320 for long strings; past that rows ellipsize and the full label is in title.
Appearance › General — font rows on SearchableSelect (2026-10-19)
The font family row (and each font override row) uses SearchableSelect at --tasty-field-width-lg 200. Its trigger is exactly as tall as the other Select rows on the same screen (On close · Language) — 28 — and carries the same ⌄ chevron instead of the old ▼ glyph.
AutoComplete — free-text trigger + candidate dropdown (typeahead)
A real typeahead: a free-text trigger (Input language) with a floating candidate dropdown (menu container + MenuItem language) that narrows as you type. Use it — not Select — when the user types free text and the list only suggests (paths, recent files, recent directories). Select stays the closed picker for a fixed value set. Path candidates render middle-ellipsis (filename tail preserved); the matched run is highlighted in --tasty-accent-primary. Two row states: pointer hover (--tasty-overlay-hover) vs keyboard ↑↓ active (--tasty-surface-active, stronger — wins when both). maxDropdownHeight caps the list; past it the list scrolls internally (.tasty-scroll) and shrinks-to-fit below.
Replaces the source-only Combobox widget (crates/tasty-ui-widgets/src/combobox.rs), which implied a closed selection it never was — its one real job is free-text + suggestions = autocomplete. Match: substring is the recommended default (paths match anywhere, not just the prefix); the matched run is highlighted — v1 source list was unfiltered, this design formalizes typeahead. Scrollbar: reuses the shared .tasty-scroll (neutral-400 thumb) — no new token. This is the candidate dropdown behind the shared PathField (see Plugins → Explorer): Explorer injects folderOpen + recent directories, Markdown injects file + recent files; the component emits only the confirmed path string. Source home for the extracted widget: rename Combobox → AutoComplete, compose it under PathField, wire both call sites — tracked as separate implementation work.
Text caret — steady, never blinks (2026-10-10 b12)
The caret in every UI text field (palette search, settings fields, popup and modal inputs, single- and multi-line) is steady: always drawn while the field has focus and the window is focused, never blinking. This matches the terminal cursor (motion 0 ms) and keeps an idle focused field from waking the frame loop twice a second. Focus is already carried by the field's focus border, so the caret does not need motion to be found. Window not focused → no caret, as today.
StatusDot · Spinner · Toast
StatusDot
Small state indicator for surfaces, panes, plugins. agent (mauve, pulsing) marks a live agent surface — the single most important signal in the product. pulse for live/running. needs-input and completion are attention kinds, not execution states — they share this one dot and win it by rank (needs-input › completion › running/agent/waiting › idle).
Status resolution — always exactly one dot
A surface carries two independent facts: who owns it (user / agent) and what it's doing (running / waiting / error / idle). These never render as two dots — they collapse into one by a fixed priority. Live activity outranks ownership, so agent + running shows running (green); the mauve agent dot appears only when the surface is otherwise idle.
Don't stack an ownership dot next to an activity dot. One surface = one dot. If you think you need two, you're encoding two facts that the priority order already resolves into one.
Spinner
Indeterminate progress for short background work — a port scan, plugin install, surface attach, remote shell detection. A thin rotating arc on a faint track, inheriting the current text color so it sits inline in muted contexts or recolors on an accent button. Not for determinate progress (use a bar) or long waits. Honors prefers-reduced-motion — falls back to three static dots.
It inherits color — drop it on a filled button and it turns --tasty-text-on-accent automatically. Used by the Port Scanner scan state and the Remote tool’s shell-detection row.
Toast
Transient notification card with an accent rail by intent — Copied, Path copied, Force detach. Terse, second person. hint (2026-10-06) is the shortcut of the action that raised the notice, shown only when the action came from a menu or the mouse (pressing the key already taught it) and only if the binding is set — an unbound action shows no hint. It sits at the right end on the first line; the body wraps before the hint ever shrinks. The agent variant is catalog-only: the host never toasts agent-originated results (they are logged), so no host notice uses it.
Toast stack
When several notices fire close together they stack rather than replace — they grow upward from the anchor corner, so the newest card sits at the bottom and older ones are pushed up, each keeping its own intent rail. A scope holds at most 5 at once; a sixth arriving drops the oldest (topmost) card immediately. Cards that would run past the scope's top edge are simply not drawn — there is no overflow row. Every floating card is fully opaque regardless of age; alpha is only for enter / exit. Same card as a single Toast; only the layout (vertical gap, order) is new. Width (2026-09-29): each card keeps its own content width, capped at --tasty-toast-max-width; the stack aligns right edges to the anchor, so a short notice is narrower than a long one. There is no shared stack width, so nothing re-flows when a card enters or leaves.
Don’t let the stack grow unbounded, and don’t fold the tail into a “+N more” row either. Hold the cap by dropping the oldest card — a wall of toasts buries the newest signal, and an overflow counter is one more thing to read instead of the notice itself.
The dot family — 8 generic, 6 in dense chrome, and the attached ring
Dots come in one role and two footprints. The generic status dot is 8 (badges, tags, list rows). Inside 24px-tall chrome — the pane tab strip, the workspace status bar, the collapsed sidebar rail — it is 6 (--tasty-status-dot-size-compact): 8 crowds a 24px row the moment a ring or a label sits beside it. That settles three drifting numbers at once — the tab busy dot keeps its 6 (it is not an alias of the generic 8 any more), the status bar's 7 becomes 6, and the rail's 6 is now named rather than hard-coded. The active tab marker stays 4: a different role (position, not state) and untouched. The attached ring adopts the token pair 2 / 2 — offset measured from the dot's outer edge to the ring's inner edge — replacing the 1.5 / 1.5 the product drew; in 24px chrome the whole mark is 6 + 2×(2+2) = 14 (11.9 / 14 / 16.8). Corrected 2026-09-28: the one place a ring actually ships is the expanded-sidebar workspace row, a list row on the generic 8 — its mark is 8 + 2×(2+2) = 16 (13.6 / 16 / 19.2) and it sits in a reserved 16px slot (next spec). The 14 figure applies to no shipping surface today.
Don't push the whole family to one number. A dot in a 24px strip and a dot on a 36px list row are the same meaning at two densities; collapsing them either crowds the strip or shrinks every badge.
Unchanged by this decision: every badge, tag and status consumer of the generic 8; the 4px active marker; the dot colours and their meanings (idle / busy / attached / needs-input / completion).
Attached ring in the workspace row — a reserved 16px slot (2026-09-28)
The workspace row keeps the generic 8 dot, so the attached mark is 16. The row reserves a 16px dot slot (--tasty-workspace-dot-slot) on every row, attached or not, and centres the dot in it. Row left inset 8 (--tasty-workspace-row-padding-x), slot → body 4 (--tasty-workspace-dot-gap). Body x = 8 + 16 + 4 = 28 for title, remote pill and subtitle alike. Clearances from the ring's outer edge at scale 1: card edge 8, active accent bar (x 0–2) 6, label 4. All three are tokens and scale with ui_scale. Rounding (2026-09-29): the slot is derived after rounding, never rounded on its own: slot = round(dot) + 2 × (round(ring-width) + round(ring-offset)). At 0.85 that is 7 + 2 × (2 + 2) = 15 (not round(13.6) = 14), so the ring always fits its slot; at 1.2 it is 10 + 8 = 18. The slot is square and its height is the title's own line box, so it never adds row height. The active bar is a hairline (--tasty-workspace-row-active-bar-width, 2, zoom-exempt). Compact 6 was rejected: this is a list row, and the 8 = list rows rule stays exception-free.
Don't size the slot to the dot and let the outline overflow into padding and gap. The kit's 8 / 8 held the label clear, but the ring still reached the active accent bar. Reserve the whole mark.
CenterState — empty · loading · error
One centred block for every empty list (2026-09-28)
The file picker, the remote-attach popup and Settings › Misc › Scripts all show the same block where their list would be: glyph or spinner, a title line, and an optional sub line. It is one part with three variants. Glyph = 24 (--tasty-icon-size-lg, new icon tier) for both the old centre glyph 22 and the scripts glyph 26. No fixed height: the block centres in the region it replaces, so the product's 100 and the specimen's 120 both go. The sub line slot is always reserved, one caption line even when empty, so loading → empty → error never moves the glyph. Everything is a token, so the block scales with ui_scale (0.85 / 1 / 1.2).
Clipboard centre glyph 28 is a content glyph (T6, 2026-09-17) and stays outside this part.
Error glyph and the action slot (2026-09-29)
The part owns the error glyph: always alertTriangle, in every host (CENTER_STATE_ERROR_GLYPH). Hosts choose only the empty glyph (folderOpen, scriptFile, remote …). The optional action (Retry / Reconnect / New workspace) is a part slot: Button secondary · sm, --tasty-center-state-action-gap (12) below the reserved sub slot, horizontally centred. It is outside the centring: only glyph · title · sub are centred, so error with and without an action puts the glyph in the same place (see the last column above). The block is now the DS component CenterState; the file picker and remote attach frames render it.
Don't put the button in the centred column. Loading → error + Retry would jump the glyph up by half the button, and with no animation that reads as a glitch.
Unsized host + action — symmetric natural height (2026-09-29)
When the host gives no height (natural; the Settings › Misc › Scripts list is the one such host today) the part takes its own height. Without an action that is the block plus --tasty-space-md above and below. With an action the part reserves the action band on both sides: padding-block = space-md + --tasty-center-state-action-gap + --tasty-button-height-sm = 48. The block stays centred, so the glyph sits where it does without the action; the action ends space-md (12) above the bottom edge and never overflows into the next widget. The cost is 36 of empty space above the glyph, accepted. The cards below have no fixed height; the part sets it.
HelpHint · Tooltip
HelpHint
An inline circular ? after a label that explains it on hover — how we move a below-control description up beside its label so a settings row stays a single line. Muted at rest, brightens on hover / focus; anchors a Tooltip. Hover-only, no click; cursor is help.
Replaces the below-control caption on dense pages (Settings › Terminal › Performance). The description copy is unchanged — it just moves into the bubble. Don't stack a HelpHint and a caption line for the same control.
Tooltip
The hover bubble itself — opaque raised card, 1px edge, popover lift, no arrow (calm, low-chrome). Wraps any anchor; shows on hover or keyboard focus after a short delay. Reusable beyond HelpHint. Shown here forced-open via open.
Don't put actions, links, or long paragraphs in a tooltip — it's a hover hint, not a popover. If it needs a click target, that's a MenuItem popup.
Hint text
Hint text
The system’s secondary helper line — a sub-label under a field, a one-line keyboard hint in a dialog, the explanatory caption beneath a setting. Always --tasty-text-muted, 11–12px, sentence case, terse. It explains without competing with the primary label. Inline Kbd and mono spans are allowed; full sentences are not the goal.
This is the same muted treatment used in the palette footer (↑↓ navigate), the Rename dialog hint, and every form sub-label — one consistent voice for “the quiet line.”
CLI runs inside UI copy (2026-10-10 b11)
The proportional UI face joins two hyphens into one dash, so --webhook-port reads as an en dash. Any CLI command, option or argument inside a UI sentence (error screen, banner, settings caption, result card) is drawn as a code run: mono at the sentence's own size, a surface-raised fill, 4px side padding, no vertical padding (the line height does not change), radius-sm, text-primary ink. The run never breaks inside; the sentence wraps around it. The copy keeps the literal option, so it can be typed back. Terminal content is not affected.
Code runs — on raised containers · info modal chips · line start · centred hints (2026-10-10 b12)
On a raised container (toast card, tooltip, any surface-raised box) the run's fill would equal the card, so it switches to --tasty-ui-code-bg-on-raised (bg-panel — a recessed well; ink on it Mocha 11.34 · Latte 7.06). Rule: the fill is always one step off the container — surface-raised on panels and app bg, bg-panel on raised. Info modal command chips become ordinary code runs: sentence size, 4px padding, radius-sm (the caption-size chip without padding goes). A run that starts a wrapped line keeps its text on the column and lets the fill extend 4px outward — acceptable, because every container that holds copy has at least space-sm inset. Centred hints centre every line, including the line the run moved to.
Table
Table
Column-config data table for system data — listening ports, processes, sessions, keybindings. Data cells default to --tasty-font-mono so ports/PIDs/addresses align by glyph. Headers are sortable and sticky (wrap in a scroll container); rows hover and select. Right-align numeric columns.
Columns can be marked tight — zero horizontal padding + centered — for narrow icon/control columns (the Listening-ports star toggle sits in a tight 28px leading column).
This is what the Tools › Listening ports popup is built from. Use render(value, row) to compose cells — e.g. a StatusDot for the state column or a Tag for the PID.
ListCtrl
ListCtrl — navigation list
A full-width, row-selectable navigation list — "pick one to drill into", not a data grid (that's Table). Each row is a primary label with an optional description, leading icon, a trailing slot (a Tag/Badge — e.g. Active), and a drill-in chevron. Rows share the list-idiom states: hover → --tasty-overlay-hover, selected → --tasty-surface-active + a 2px accent left bar, divided by the separator hairline. Pair it with the Drill-down layout (see Layouts) for the list → detail swap — this is the new Settings › Keybindings › Preset list.
Do reach for ListCtrl when a row leads somewhere (drill-down, apply). Don't use it for tabular data with multiple comparable columns — that's Table. The trailing slot is for one status marker (Active / a count), not a second data column.
Disabled row with a trailing marker — the ink rule (2026-09-29)
A disabled row follows the same rule as a disabled control: ink, never opacity. Icon, label, description take --tasty-state-disabled-fg; the chevron goes; no hover, not selectable. The trailing Tag / Badge drops to its disabled variant: the neutral box (--tasty-tag-disabled-bg / -border, --tasty-badge-disabled-bg) with the same ink. Accent fills and tint edges drop out, so the disabled ink never sits on an accent. The marker is not hidden: what it says (Active, a count) is still true of the row. Tag and Badge also take a disabled prop for use outside ListCtrl.