tasty.gallery
Components
← Design
Specs
Theme

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.

Layout spec
height
28 / 24 / 32px
padding
0 --tasty-space-md
radius
4px --tasty-radius
hover / active
8% / 12% overlay
focus
2px accent ring
Tokens used
accent-primary— primary fillaccent-danger— danger fillaccent-agent— agent filloverlay-hover— 8% hovertext-on-accent— filled label

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.

Layout spec
fill / edge
surface-raised / border-default — every variant (ghost: none)
label + icons
text-disabled (one ink)
accent fill
drops out when disabled
opacity
none on disabled controls
hover / active
not drawn
cursor
default
dimmed items
switched-off rows · pending cut · inert regions → state-dim-opacity (0.5), not this rule
Tokens used
button-disabled-bg— → state-disabled-fillbutton-disabled-border— → state-disabled-borderbutton-disabled-fg— → state-disabled-fgstate-dim-opacity— items only

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

Layout spec
size
28px (sm 24)
shape
square, 4px radius
active
--tasty-accent-primary
Tokens used
overlay-hover— hover tintaccent-primary— activetext-muted— rest icon

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.

Layout spec
radius
--tasty-radius-pill
size
caption 11px
group gap
--tasty-badge-group-gap
overflow
99+
Tokens used
badge-danger-bg— defaultbadge-warning-bg— NeedsInput countbadge-primary-bg— Completion countbadge-agent-bg— agentfont-size-caption— 11px

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.

Layout spec
font
--tasty-font-mono
radius
--tasty-radius-sm
dot
leading state dot
Tokens used
font-mono— labelborder-default— outlineaccent-agent— agent

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.

Tokens used
font-mono— keycapsurface-raised— cap fillborder-default— cap border

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.

Layout spec
min side
16 — --tasty-kbd-size (was 18)
padding-x
4 — --tasty-kbd-padding-x (was 5)
gap
3 — --tasty-kbd-gap (was 4)
font
10 — --tasty-kbd-font-size (was 11)
bottom edge
unchanged — --tasty-kbd-shadow-depth
row
28px palette row — unchanged, 6px air per side
new tokens
none
Tokens used
kbd-size— cap min sidekbd-padding-x— cap side paddingkbd-gap— between capskbd-font-size— cap label

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.

Layout spec
toggle check
12 → --tasty-icon-size-xs
spinner default
16 → --tasty-icon-size-md
switch-overlay digit
16 — stays on --tasty-kbd-size (a keycap, not an icon)
clipboard glyph
30 → 28 (the existing content-glyph exception)
central glyph 22 / empty glyph 26
unchanged named exceptions
new font tokens
none
Tokens used
icon-size-xs— 12 — inline glyphsicon-size-md— 16 — toolbar / spinnerspinner-size— → icon-size-md

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.

Layout spec
height
28px --tasty-control-height
border
1px → focus ring 2px
padding
0 --tasty-space-sm
Tokens used
surface-raised— fillborder-focus— ringaccent-danger— invalidtext-placeholder— hintinput-readonly-fg— read-only value → text-secondary-raised

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.

Layout spec
control height
28px
checkbox
16px square
accent
--tasty-accent-primary
Select ink (2026-10-21)
value = text-primary · no value = select-placeholder · disabled (value or not) = state-disabled-fg, chevron state-disabled-fg (else text-muted) · open list options always text-primary · no hover/active overlay when disabled
Tokens used
accent-primary— checked / onsurface-raised— track / boxborder-default— outline

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.

Layout spec
trigger
Select language — 28px --tasty-multiselect-height · 12 left · 28 chevron room
open trigger
border-focus + 1px ring, chevron rotates 180°
summary
3 branches, caller-injected · plain text · single line + ellipsis
menu
4 below the trigger · min-width = trigger · grows to content up to --tasty-multiselect-menu-max-width (320)
row
28px · 12 padding-x · 8 box→label · Checkbox 16px
checked row
checkmark only — no background
overflow
scrolls past 220 --tasty-multiselect-menu-max-height · .tasty-scroll
bulk row
allToggle — accent action row + separator, off below ~8 options
keys
↓/↵/Space open · ↑↓ Home End move · Space/↵ toggle (menu stays) · Esc close
motion
border/chevron 120ms · check state 0ms
Tokens used
multiselect-bg— trigger fillmultiselect-border— trigger edgemultiselect-border-focus— focus / openmultiselect-summary-fg-empty— 0 selectedmultiselect-menu-bg— menu fillmultiselect-row-bg-hover— pointer hovermultiselect-row-bg-active— keyboard-activemultiselect-row-fg— row label (primary, not muted)checkbox-bg-checked— checked boxmultiselect-all-fg— bulk select/clear

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

Layout spec
trigger
= Select · select-height 28 · pad 12 left / 28 chevron room · 1px select-border · radius 4 · chevron ⌄ 14 at select-chevron-offset 8, rotates 180° when open
trigger states
rest select-border · hover border-strong · open/focus select-border-focus + 1px ring · disabled state-disabled-fill/-border/-fg · placeholder text-placeholder · long value end-ellipsis, never grows
popup
4 below trigger · min-width = trigger · max-width 320 · max-height 300 (whole popup) · menu-bg · 1px menu-border · menu-radius · shadow-popover · padding 4
search field
content width (popup − 2 × 4) · 28 tall · pad-x 8 · search icon 14 + gap 8 · input-raised-bg / input-raised-border · focus = border-focus edge, NO ring · body 14 · placeholder text-placeholder
separator
1px menu-separator (border-strong) · 4 above · 4 below (menu-sep) · a second one under pinned rows
option row
MenuItem · 28 · pad-x 12 · rest text-secondary · hover menu-item-bg-hover · keyboard-active surface-active · selected = menu-item-selected-fg + trailing check 14 (no fill) · label ellipsis
status rows
no match / loading · 28 · pad-x 12 · body 14 · text-secondary-raised · no hover, cursor default, aria-disabled · loading = Spinner 14 + gap 8 + text, after any rows already present
scroll
only the option list scrolls (.tasty-scroll); field, separators, pinned rows stay put
Tokens used
select-search-list-max-height— → size-300 (font-combo-list-max-height now aliases this)select-search-inset— → space-xs 4 (font-combo-search-inset now aliases this)select-search-menu-gap— → space-xs 4select-search-menu-max-width— → size-320select-search-field-height— → input-height → control-height 28select-search-field-padding-x— → space-sm 8select-search-field-gap— → input-gap → space-sm 8select-search-field-bg— → input-raised-bg → bg-panelselect-search-field-border— → input-raised-border → border-strongselect-search-field-border-focus— → input-border-focus → border-focusselect-search-placeholder— → input-placeholder → text-placeholderselect-search-icon-fg— → input-icon-fg → text-mutedselect-search-status-fg— → text-secondary-raisedselect-search-trigger-border-hover— → border-strongselect-search-value-placeholder— → text-placeholder

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.

Layout spec
row
settings label · settings-label-gap · trigger field-width-lg 200 · row min settings-row-min-height
height check
font trigger 28 = Select 28 (select-height) · chevron ⌄ icon chevronDown 14
override rows
same control, same width, per-font override in the Appearance list

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.

Layout spec
trigger
Input language · leading icon slot · mono · focus ring on open
dropdown
menu container + MenuItem rows · popover lift
row height
28px --tasty-control-height
path row
middle-ellipsis (filename tail kept)
filter
substring (default) · prefix · none
row states
hover (overlay-hover) · keyboard-active (surface-active, wins)
overflow
scroll past maxDropdownHeight · .tasty-scroll
empty
one muted no-match row
Tokens used
input-bg— trigger fillinput-border-focus— trigger border (open)autocomplete-menu-bg— dropdown fillautocomplete-menu-border— dropdown edgeautocomplete-row-bg-hover— pointer hoverautocomplete-row-bg-active— keyboard-activeautocomplete-match-fg— match highlightautocomplete-max-height— default cap (220)autocomplete-empty-fg— no-match row

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.

Layout spec
blink
off · egui text_cursor.blink = false
width
1 · text-primary ink
shown
field focused AND window focused
cost
no idle repaint while a field is focused
Tokens used
border-focus— focus edge

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

Layout spec
dot
7px
pulse
expanding ring
agent
--tasty-accent-agent
attention rank
needs-input › completion › activity
Tokens used
accent-success— runningaccent-agent— agentaccent-warning— waitingaccent-danger— errorstatus-dot-needs-input— needs inputstatus-dot-completion— completion

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.

Layout spec
priority
error › waiting › running › agent › idle
rule
live activity beats ownership
dots shown
always exactly 1
helper
resolveStatus(owner, activity)
Tokens used
accent-success— running (wins)accent-agent— agent (idle only)accent-danger— error (top)

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.

Layout spec
sizes
12 / 16 / 20 / 24px
stroke
2px arc + faint track
spin
0.9s linear
color
currentColor
reduced motion
→ 3 static dots
Tokens used
text-muted— default colorspinner-size— diametersize-16— 16px default

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.

Layout spec
rail
3px accent left edge
radius
--tasty-radius
fill
--tasty-surface-raised
boxes
rail · body (flex 1) · hint (flex none) · gap space-sm · pad space-sm / space-md
hint
Kbd of the action's binding · menu / mouse origin only · none when unbound
hint position
right end, first line · never truncated — body wraps first
platform
Kbd rule: ⌘ ⇧ ⌥ on macOS, Ctrl+Shift+ on Windows / Linux
agent
catalog only — host does not emit
icon
catalog only — host card has no icon
Tokens used
accent-success— ok railtoast-hint-font-size— hint · micro monosurface-raised— card

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.

Layout spec
anchor
one corner (bottom-right)
order
newest bottom
gap
8px --tasty-space-sm
cap
5 per scope → oldest dropped
width
content width per card, cap --tasty-toast-max-width (320) · right edges align
window-scope offset (b2 · 2026-10-07)
main window: --tasty-toast-stack-offset-bottom (→ size-36 = status bar 24 + 12), OFF-SCALE like the status bar and the card dims · Settings window: --tasty-toast-stack-offset-bottom-settings (→ size-64 = footer 52 + 12), ON-SCALE like the footer — the stack sits above Cancel / Save
scale
every structural toast dim (scope margin 12, padding 12 × 8, gap 8, main offset) is OFF-SCALE; only the Settings offset follows the zoom
shared corner (2026-10-07)
workspace- and pane-scope stacks that land on the same corner merge into ONE column, newest bottom, cap 5 across both
link menu Copy hint (b2)
own action copy_link ('Copy link'), unbound by default → no hint until the user binds it; never borrow the copy binding
over a native WebView (b2)
while a toast card's rect intersects a WebView, only THAT WebView is hidden for the card's life (its tile shows plain); others stay; keyboard focus is not reclaimed (a toast never takes focus). A WebView that is RECEIVING KEYS is not hidden, so the card stays under it — accepted (2026-10-07): typing must not be lost, and a toast is transient
Tokens used
space-sm— stack gapsurface-raised— each card

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.

Layout spec
generic dot
8 — --tasty-status-dot-size (unchanged)
dense chrome
6 — --tasty-status-dot-size-compact (new)
tab busy dot
6 — keeps its value, now its own role
status bar dot
7 → 6 (visible change)
sidebar rail dot
6 — unchanged, now tokenised
active tab marker
4 — different role, untouched
attached ring
width 2 / offset 2 (1.5 → 2, visible change)
attached bbox
compact 6 → 14 · generic 8 → 16 (workspace row, the only ring in product)
offset measured
dot outer edge → ring inner edge
toast gap
6 → 8 (visible change, 4px grid)
badge / tag dots
unchanged at 8
Tokens used
status-dot-size-compact— tab · status bar · railtab-dot-size— → compactstatusbar-dot-size— → compactstatus-dot-attached-ring-width— 2status-dot-attached-ring-offset— 2toast-gap— 8, unchanged token

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.

Layout spec
dot
8 — --tasty-status-dot-size (unchanged)
attached bbox
8 + 2×(2+2) = 16 → 13.6 / 16 / 19.2
row inset
product 4 → 8
dot slot
product 8 → 16, reserved on every row
slot → body
4 (unchanged)
body x
product 12 → 28 (title · pill · subtitle)
clearance
card 8 · accent bar 6 · label 4
ui_scale 0.85
inset 7 · slot 15 (derived) · gap 3 → ring 7..22, label x 25 · clear card 7 · bar 5 · label 3
ui_scale 1.2
inset 10 · slot 18 · gap 5 → label x 33 · row height unchanged
collapsed rail
unchanged — avatar square border, no ring
Tokens used
workspace-row-padding-x— → space-sm 8workspace-dot-slot— → size-16workspace-dot-gap— → space-xs 4workspace-row-active-bar-width— → selection-edge-width 2 (hairline)status-dot-attached-ring— lavender

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

Layout spec
variants
loading · empty · error
glyph / spinner
24 — icon-size-lg (was 22 · 26)
glyph → title
8
title → sub
4 · sub slot always reserved
title
body 13 · text-secondary
sub
caption 11 · text-muted · wraps at 300
height
none — centres in the list region; unsized hosts (natural): block + 2 × 12, with action block + 2 × 48
ui_scale
scales (tokens) — 20.4 / 24 / 28.8 glyph
Tokens used
center-state-glyph-size— → icon-size-lg 24center-state-glyph-fg— → glyph-dimcenter-state-error-fg— → accent-dangercenter-state-gap— 8center-state-line-gap— 4center-state-max-width— → measure-sm 300

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.

Layout spec
error glyph
alertTriangle — part-owned, every host
empty glyph
host-chosen
action
Button secondary · sm (24)
sub → action
12 · center-state-action-gap
centring
glyph · title · sub only — action hangs below
short region
action may reach the region's bottom padding; never pushes the glyph
Tokens used
center-state-action-gap— → space-md 12button-height-sm— 24center-state-error-fg— → accent-danger

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.

Layout spec
prop
natural — host gives no height
no action
padding-block space-md (12)
with action
padding-block 12 + 12 + 24 = 48, both sides
block
stays centred — glyph position independent of the action
below the action
space-md (12) — no new token
rejected
top-pinned block (glyph moves) · current overflow (6px into the next widget)
Tokens used
space-md— 12 outer padcenter-state-action-gap— → space-md 12button-height-sm— 24

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.

Layout spec
glyph
help-circle · --tasty-help-hint-size 14
rest
--tasty-help-hint-color
hover
--tasty-help-hint-color-hover
gap
4px --tasty-help-hint-gap
cursor
help — no click
Tokens used
help-hint-color— rest glyphhelp-hint-color-hover— hover glyphhelp-hint-gap— label → (?)

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.

Layout spec
bg
--tasty-tooltip-bg
border
1px --tasty-tooltip-border
radius
--tasty-tooltip-radius
max-width
240 --tasty-tooltip-max-width
placement
top / bottom / left / right
over native content
tab strip · pane head: top → bottom → inside the strip (1px border-width tolerance) · none clears → inside the strip — Layouts › Pane tab strip. No anchor cell (outside a strip): top clamped, 4 from the window edge
delay
150ms --tasty-tooltip-delay
Tokens used
tooltip-bg— bubble filltooltip-border— 1px edgetooltip-fg— copytooltip-shadow— lift

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.

Layout spec
size
11–12px
color
--tasty-text-muted
case
sentence case
line-height
1.5
placement
below the thing it explains
Tokens used
text-muted— hint colorfont-size-caption— 11pxfont-mono— inline code

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.

Layout spec
font
ui-code-font → font-mono · size = the sentence (1em)
fill
ui-code-bg → surface-raised
ink
ui-code-fg → text-primary
padding
ui-code-padding-x 4 · no vertical
radius
ui-code-radius → radius-sm
wrap
never inside the run
scope
UI copy only · not terminal content · i18n strings keep the literal option
Tokens used
ui-code-font— monoui-code-bg— fillui-code-fg— inkui-code-padding-x— → space-xsui-code-radius— → radius-sm

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.

Layout spec
on raised
ui-code-bg-on-raised → bg-panel (toast · tooltip · raised cards)
elsewhere
ui-code-bg → surface-raised (unchanged)
info modal
command chips = code runs (sentence size · padding-x 4 · radius-sm)
line start
text on the column · fill may extend 4 outside it
centred hint
every line centred (text-align center)
Tokens used
ui-code-bg-on-raised— → bg-panelui-code-bg— → surface-raised

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.

Layout spec
row height
28px --tasty-control-height (dense 22)
header
sticky, --tasty-bg-sidebar, mono caption
cell font
mono columns → --tasty-font-mono
selected
--tasty-surface-active
hairline
--tasty-separator
Tokens used
bg-sidebar— header rowsurface-active— selected rowoverlay-hover— row hoveraccent-primary— sort arrowfont-mono— port/addr/pid cells

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.

Layout spec
row
label · [description] · [trailing] · chevron
min height
36px --tasty-listctrl-row-min-height
hover
--tasty-overlay-hover
selected
--tasty-surface-active + 2px accent bar
chevron
trailing drill-in affordance (default on)
divider
--tasty-separator hairline between rows
disabled
--tasty-state-disabled-fg ink · no chevron · non-selectable · trailing Tag/Badge → disabled variant
Tokens used
listctrl-row-bg-hover— row hoverlistctrl-row-bg-selected— selected rowlistctrl-selected-bar— accent left barlistctrl-label-fg— labellistctrl-desc-fg— descriptionlistctrl-chevron-fg— drill-in chevron

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.

Layout spec
row ink
--tasty-state-disabled-fg
chevron
hidden
trailing
kept · disabled variant (neutral box + ink)
accent Tag
fill drops out
tint edge
→ --tasty-state-disabled-border
opacity
none
Tokens used
tag-disabled-bg— → state-disabled-filltag-disabled-border— → state-disabled-bordertag-disabled-fg— → state-disabled-fgbadge-disabled-bg— → state-disabled-fill