tasty.gallery
Layouts
← Design
Specs
Theme

Layouts

Explore how Tasty arranges navigation and content. Compare list-and-detail layouts with tabs and sections. Turn on Specs to inspect the grid and dimensions.

This page uses a saved copy of the design system, last changed on . It may differ from the current design source.

Pane tab strip

Tab strip — fixed 24px row

Sits at the top of a pane group on --tasty-bg-sidebar. Tabs are 24px × 150px; the active tab adopts the panel color with an accent top bar. A + IconButton follows the tabs; pane actions (split, search) pin to the right.

Layout spec
tab height
24px --tasty-control-height-tab
tab width
150px --tasty-tab-width
strip fill
--tasty-bg-sidebar
active
panel fill + accent top bar
Tokens used
bg-sidebar— stripbg-panel— active tabaccent-primary— active bar

Tab cell — the right-hand status cluster (2026-09-29)

Everything a tab reports sits in one fixed cluster at the right end of the cell, never glued to the label text. Order, left → right: HTML script marker · move glyph · busy dot · close. Items are --tasty-tab-status-gap (4) apart; the label keeps --tasty-tab-gap (8) to the cluster. The cluster never shrinks: the label ellipsises first, so markers stay visible in a narrow tab. Close keeps its slot (hidden until active / hover) so nothing shifts on hover. The lock marker has a 16 × 16 hit cell (--tasty-html-script-marker-hit) with its own hover fill; hovering it is still hovering the tab, so close shows too. The scriptFile marker is tooltip-only, no hover fill.

Layout spec
order
label … | marker · move · busy · close
cluster gap
4 · tab-status-gap
label → cluster
8 · tab-gap
squeeze
label ellipsises; cluster fixed
lock hit
16 × 16, hover = overlay-hover
lock click
re-shows the banner only (no tab switch, no focus change) · mouse only
scriptFile
tooltip only
tooltip placement
top → bottom → inside the strip (1px border-width tolerance) · none clears → inside the strip (see next spec)
Tokens used
tab-status-gap— → space-xs 4html-script-marker-hit— → size-16html-script-marker-hover-bg— → overlay-hoverhtml-script-marker-fg— lock

Tooltips in the strip open upward — native content below (2026-09-29)

Under an HTML tab the pane body is a native WebView, and the OS always draws it above Tasty's own layer. A tooltip opened down from the strip lands inside it and is never seen. So every tooltip anchored in a pane tab strip or pane head (script marker, close, scroll arrows, head buttons) opens top, over the title area or the pane above, whatever the surface kind, so the strip behaves the same everywhere. Candidates in order: top, then bottom; the first that fits the window and clears every native content rect wins. Revised 2026-10-06 — if neither clears (an HTML pane stacked above another HTML pane), a third candidate is tried: inside the strip — the bubble is centred vertically on the strip row (Tasty paints the strip, so no WebView covers it) and placed beside the anchor cell, right first, then left, --tasty-tooltip-offset from the cell. It may cover neighbouring tabs; that is the price of being seen. Revised 2026-10-06 b2 — the in-strip candidate tolerates 1px (border-width) per edge, and when every candidate fails the bubble is still placed inside the strip, the one place Tasty paints over. Top clamped (--tasty-tooltip-offset as the vertical margin, never flush at y = 0) remains only for tooltips with no anchor cell, i.e. outside a strip. Horizontally the bubble clamps to the window edges with --tasty-tooltip-offset. Hiding the WebView while a tooltip is up is rejected: the page would flash on every hover. Hover delay, copy and click are unchanged.

Layout spec
scope
every tooltip anchored in a pane tab strip or pane head
placement
top → bottom → inside the strip; first that clears all native rects
inside the strip
centred on the strip row · beside the anchor cell (right, then left) · tooltip-offset from the cell · may cover neighbour tabs
overlap tolerance (b2)
the in-strip candidate is judged against native rects with a tolerance of border-width (1px) per edge — a one-line bubble (25) on the 24 strip passes; nothing else changes size
last resort (b2)
if every candidate fails, the in-strip placement is used anyway (it is the one Tasty paints over); clamped top is no longer the final fallback
specimen (b2)
themes stacked vertically; real copy; the bubble may run past the example window inside the stage padding — the app clamps to the app window, not to a pane
none clears
superseded 2026-10-06 b2 → in-strip placement (see last resort)
horizontal
clamped to window edges · tooltip-offset 4
WebView hiding
not used for tooltips
delay · copy · click
unchanged
Tokens used
tooltip-offset— → space-xs 4 · anchor gap + edge clamptooltip-bg— bubble

Scroll arrows — chevron icon, square cell, no own fill (2026-09-29)

The strip's scroll arrows are icons: chevronLeft / chevronRight at --tasty-tab-scroll-arrow-glyph-size (14 since 2026-10-06 — at 12 the 1px stroke lands on a pixel seam and rasters at half alpha). The < > characters go: a text character is not an icon. The cell is square, --tasty-tab-scroll-arrow-width = strip height 24 (was 20; the tab viewport loses 4 per side, accepted). The cell has no fill of its own; it sits on the strip ground (focused pane surface-raised, unfocused bg-sidebar). Hover lays --tasty-tab-scroll-arrow-hover-bg on the enabled side only. The same shape and roles apply to every horizontal tab bar with arrows, including horizontal_tab_bar_with_arrows (its 0.4 tint retires; the end it has reached is disabled). Contrast is judged on flat colour like every other token ratio; rasteriser AA is not part of the target.

Layout spec
glyph
chevronLeft / chevronRight · 14
cell
24 × 24, no fill
hover
overlay-hover, enabled side only
disabled
the end reached · no hover · no response
scope
pane strip + horizontal_tab_bar_with_arrows
3:1 basis
flat colour (Mocha 5.65 · Latte 3.65)
Tokens used
tab-scroll-arrow-width— → control-height-tab 24tab-scroll-arrow-glyph-size— → icon-size-sm 14tab-scroll-arrow-fg— → text-mutedtab-scroll-arrow-fg-disabled— → text-disabled

Don't draw arrows with a font glyph. A proportional < at 11px renders thinner than the icon stroke and depends on the UI font.

Move source scrolled out of view — the arrow on that side turns pink (2026-09-29)

When the pending move target (a tab, or a surface inside an inactive tab) is in the active workspace but its tab cell is scrolled out of the strip, the scroll arrow on that side takes --tasty-tab-scroll-arrow-move-fg (= move glyph pink). Tab targets and surface targets use the same cue. Exception (2026-10-06): a surface inside the active tab gets no arrow cue even when that tab's cell is scrolled away — its ring is drawn in the content area, so the target is on screen; the arrow only ever points at a target you cannot see. A cell counts as visible only when it is fully inside the viewport; until then the arrow carries the cue. The cue is static and behaves like the arrow: hover is the arrow's hover, a click is the arrow's normal one-step scroll (no jump). It ends when the cell comes fully into view; the ring or glyph in the cell takes over. That side can never be disabled while the target is beyond it. A rect too small for the ring (under 2 × ring width) gets no substitute: it cannot happen at the minimum pane size. Pane maximise does not exist yet; request it separately when it does.

Layout spec
where
the scroll arrow on the target's side
colour
accent-move (same as the glyph)
tab · surface target
same cue
surface in the ACTIVE tab
no cue — the content-area ring already shows it (2026-10-06)
visible
cell fully inside the viewport
input
none of its own — the arrow's normal step scroll
too-small rect
no substitute
maximise
out of scope — new request later
Tokens used
tab-scroll-arrow-move-fg— → move-source-glyph

Nothing new appears in the strip. The arrow already means "more tabs this way"; the pink says the move source is one of them.

1-depth — list → detail (Plugins / Tools idiom)

One sidebar, one detail pane

The simplest content layout: a fixed-width list (a TreeRow list of items) feeding a flexible detail pane. Use it for plugins, tools, port lists, file handlers — anything that's "pick one from a list, see its detail." No top tabs.

Layout spec
list width
~200px fixed
detail
flex, 18px pad
list fill
--tasty-bg-sidebar
selected
--tasty-surface-active
Tokens used
bg-sidebar— listbg-panel— detailsurface-active— selectedspace-lg— detail pad

Drill-down — content swap (list ⇄ detail)

One area, swapped — list → detail → back

When the model is "one full-width list, pick an item, see its detail, go back" — and a side-by-side split would starve both — the content area swaps in place. The list view (a full-width ListCtrl) becomes a detail view the moment you select a row; a pinned back bar (← + title) returns you. This is a generic DrillDown layout — its first home is Settings › Keybindings › Preset (preset list → diff preview), replacing the old cramped 120px-list + right-preview split. Select a preset below, then press ←.

Layout spec
views
list (full width) ⇄ detail (full width)
back bar
36px, --tasty-drilldown-backbar-height · bottom hairline
back button
ghost IconButton · chevronLeft
title
detail subject, next to ←
actions slot
right of the back bar — holds Apply
transition
instant (reduced-motion aware opt-in fade)
scroll
detail body scrolls; back bar pinned
Tokens used
drilldown-backbar-border— back-bar bottom ruledrilldown-title-fg— detail titlesurface-active— selected list rowaccent-primary— Apply / active bar

Do put the detail's own action (Apply) in the back-bar actions slot — it stays clear of a surrounding modal footer's Cancel / Save, so the two button rows never collide. Apply is disabled for the already-active item (nothing to change).

Don't reach for drill-down when items are few and detail is small — a plain 1-depth list→detail split shows both at once. Swap only when the detail (or the list) genuinely wants the whole width.

2-depth — tabs → sections → content (Settings idiom)

Top tabs over a filterable section list

When one list isn't enough, add a top tab row (L1) above the list. L1 is a small fixed set; the left list (L2) is the growable, filterable dimension. This is the Settings window's structure — reuse it for any surface whose content tree outgrows a flat list.

Layout spec
L1 bar
40–44px, accent underline
L2 list
168px, filter header
content
flex, 18px pad
rule
L1 fixed · L2 grows
Tokens used
bg-sidebar— L1 + L2accent-primary— active tab/presetsurface-active— active section

Do give plugins their own L1 tab. Don't nest a growing plugin list inside another group's L2.

Multi-tier tab layout

Stacked tab tiers — workspace over panes

When a window holds more than one level of switchable context, tabs stack into tiers: a top workspace tier (which project) over the per-pane tab strip (which surface). Each tier is a full tab row in its own band — the upper tier sits on --tasty-bg-app, the pane strip on --tasty-bg-sidebar. Only the active path down the tiers is highlighted, so the hierarchy reads at a glance.

Layout spec
tier 1
32px, --tasty-bg-app
tier 2
24px Tab strip, --tasty-bg-sidebar
active path
one highlight per tier
order
broadest context on top
Tokens used
bg-app— tier 1 bandbg-sidebar— tier 2 bandsurface-active— active workspaceaccent-primary— active pane bar

Don't stack more than two tiers — beyond that, move the broadest dimension into the sidebar. Tiers are for switchable context, not deep trees.

Divider

Pane divider — the resize boundary

The 1px separator between split panes, doubling as the drag-to-resize handle. At rest it's a hairline (--tasty-separator); on hover the hit-band (a ~6px transparent zone around the line) lights to the accent and the cursor becomes a resize arrow. Works on both axes — a vertical divider splits left/right, a horizontal one splits top/bottom.

Layout spec
line
1px --tasty-separator
hit-band
~6–8px transparent
hover
line \u2192 --tasty-accent-primary
cursor
col-resize / row-resize
axes
vertical & horizontal
Tokens used
separator— rest lineaccent-primary— hover / dragging

Hover either divider above — the hairline turns accent. The hit-band is wider than the visible line so the 1px boundary stays easy to grab without thickening the resting visual.

Surface focus states

Focused · unfocused · agent

Split panes make focus explicit through color. The focused terminal surface goes true black (#000) with full-strength text; unfocused surfaces dim to --surface-terminal-unfocused at 92% opacity. An agent surface carries the mauve pulsing dot — you always know who's driving.

Layout spec
focused bg
#000 --tasty-surface-terminal-focused-bg
unfocused bg
--tasty-surface-terminal-unfocused-bg
unfocused opacity
0.92
agent marker
pulsing --tasty-accent-agent
Tokens used
surface-terminal-focused-bg— #000 focusedsurface-terminal-unfocused-bg— dimmedaccent-agent— agent dotaccent-success— your running dot

This is the soul of the product made visual: user vs. agent and focused vs. not are never ambiguous. Carry these colors into any surface mock.

Mixed split — terminal + markdown in one pane group

A pane group is kind-agnostic: a terminal and a markdown surface can sit side by side in the same tab — the common "run it left, read the doc right" layout. Each surface keeps its own focus bed: the terminal goes #000 when focused, the markdown surface uses the document bed (--tasty-surface-markdown-focused-bg) focused and the sidebar bed unfocused — never black. The markdown surface keeps its 36px address bar inside the split, and the status bar reports the focused surface, not the tab.

Layout spec
group
one tab, surfaces of different kinds
terminal focused
#000 · opacity 1
markdown unfocused
--tasty-surface-markdown-unfocused-bg
markdown chrome
36px address bar, kept in split
prose measure
max 620px
status bar
focused surface id
Tokens used
surface-markdown-focused-bg— document bed (focused)surface-markdown-unfocused-bg— doc surface, unfocusedsurface-terminal-focused-bg— #000 terminal, focusedseparator— surface border

Don't paint the markdown pane black to "match" its terminal neighbour — black is a raw-TTY convention. Prose stays on the Catppuccin document bed, which is how the two kinds stay tellable apart at a glance.

Occupancy & completion borders

Three surface states ride one visual channel — the terminal surface's border — and are told apart by color alone, so the palette must stay mutually unmistakable at a thin edge. Occupied · soft (green, 1px): a subject (remote user or AI agent) holds the surface — a cooperative heads-up, no write restriction; it may close or change at any time. Occupied · hard (peach, 1px): the same notice plus readonly (input blocked, mirror-observe) — it carries a force-detach affordance top-right and absorbs the old remote-attach border. Completed (blue, 2px): a surface-highlight drawing attention to a finished task; it clears on focus. Occupancy outranks the completion highlight when both apply (a source-side priority rule, not a token).

Layout spec
channel
surface border — color-only
soft
green, 1px — held, writable
hard
peach, 1px — readonly + force-detach
completed
blue, 2px — clears on focus
priority
occupancy > completion (source rule)
Tokens used
surface-occupied-soft-border— soft edge (→ green)surface-occupied-hard-border— hard edge (→ peach)surface-highlight-done-border— completion edge (→ blue)surface-occupied-border-width— occupancy edge = 1pxsurface-highlight-done-width— completion edge = 2px

Why blue, not sky, for completion. The ADR describes the completion border as “sky”, but the live source draws it blue (--tasty-accent-primary) and the design confirms blue as canonical: against the green (soft) and peach (hard) neighbours on the same channel, blue is the most discriminable — sky sits too close to green at a 1px edge. Hard reuses the peach primitive but through its own role (--tasty-accent-occupied-hard), kept distinct from --tasty-accent-attention (plugin needs-attention) so the two intents can diverge. Completion is one attention kind; the second kind (NeedsInput, yellow) and the full cross-channel priority live in Attention kinds below.

Attention kinds

The scale — kind → color → rank

A background subject (agent, task, remote session) raises attention on a target. Attention is a scale, not a pair of colors: each kind takes one semantic slot (--tasty-attention-<kind>) that borrows an existing accent role, plus a rank. Every channel that renders attention — workspace badge, collapsed rail dot, tab title, surface border — reads the same slot, so adding a kind is one token line per channel and no new logic.

Layout spec
kinds (live)
needs-input · completion
reserved
error (rank 40) · approval (rank 20)
rank spacing
10 — a future kind slots between without renumbering
collision rule
highest rank takes the single visual slot
contrast
fill × on-accent fg — both live kinds clear 4.5:1
Tokens used
attention-needs-input— blocked, waiting on the human (→ yellow)attention-completion— task finished, FYI (→ blue)attention-needs-input-fg— glyph atop the yellow fillattention-completion-fg— glyph atop the blue fillattention-rank-needs-input— 30attention-rank-completion— 10

NeedsInput outranks everything because it is the only kind that is a request — work has stopped until the human answers. Completion is an FYI and clears on focus. No attention kind mints a new hue; a kind that can't honestly borrow an existing accent role is a sign the role list is missing something.

Workspace row — one or two count badges

The expanded sidebar row can carry a badge per kind. The trailing slot is the badge slot: with one kind present it sits there regardless of which kind it is, so a row never shifts its badge position by kind. With both, NeedsInput leads and Completion trails — higher rank reads first in a left-to-right scan — separated by --tasty-badge-group-gap. Counts over two digits collapse to 99+ for both kinds.

Layout spec
slot
row trailing edge — kind-independent
order (both)
needs-input → completion
gap
--tasty-badge-group-gap
overflow
99+ — both kinds
height
16px badge, micro 10px numerals
Tokens used
badge-warning-bg— NeedsInput badge fillbadge-warning-fg— NeedsInput numeralbadge-primary-bg— Completion badge fillbadge-primary-fg— Completion numeralbadge-group-gap— 4px between the two

Don't reserve an empty slot for the missing kind. A fixed two-slot row makes every quiet workspace look like it has holes in it; the group is inline and collapses to the count that exists.

Collapsed rail — one dot, highest rank wins

The rail has one dot slot per workspace (the compact 6px dot, --tasty-status-dot-size-compact — the rail is 24px-class chrome) and no room for a number, so the dot answers “what is the most urgent thing here”, not “how many”. It is always a single dot, colored by the highest-ranked live state: NeedsInput › Completion › busy/running. Two dots were considered and rejected — at 6px on the rail a second dot reads as noise, still carries no count, and breaks the one-slot alignment the rail depends on. The count is one click away in the expanded sidebar.

Layout spec
slot
1 dot, top-right of the avatar
size
6 · --tasty-status-dot-size-compact
ring
1.5px --tasty-status-dot-ring (→ bg-sidebar) on every dot, busy included — keeps the dot legible over any avatar
order
needs-input › completion › busy
count
not shown — expand the sidebar
Tokens used
status-dot-needs-input— rail dot — NeedsInputstatus-dot-completion— rail dot — Completionstatus-dot-success— rail dot — busy/runningstatus-dot-size-compact— 6pxstatus-dot-ring— cut-out ring (→ bg-sidebar)status-dot-ring-width— 1.5px — OFF-SCALE hairline

Attention beats activity. Busy-green says “something is happening”, which is the normal state of this product and the least actionable thing on the rail; both attention kinds are events that want a human. The previous code already preferred highlight over busy — this makes it a rule.

Tab title & surface border — the priority ladder

The pane tab strip tints the title color and the surface tints its border. Both are single channels shared with non-attention states, so both resolve by rank. Tab title: NeedsInput → Completion → active → rest. Surface border: NeedsInput (2px yellow) → occupancy (1px green/peach) → Completion (2px blue) — NeedsInput steps above occupancy, the one change to the earlier occupancy-over-completion order.

Layout spec
tab — needs-input
--tasty-tab-fg-needs-input
tab — completion
--tasty-tab-fg-completion
tab — active
--tasty-tab-fg-active
tab — rest
--tasty-tab-fg
border — needs-input
2px, inside
border priority
needs-input > occupancy > completion
Tokens used
tab-fg-needs-input— blocked tab titletab-fg-completion— finished tab titlesurface-highlight-input-border— blocked surface edgesurface-highlight-input-width— 2px — matches completion

Why NeedsInput outranks occupancy. Occupancy reads as “held, working, as expected” — which is exactly the state a blocked prompt would hide behind. A session that has stopped to ask you something must not look like a session that is busy. Completion stays below occupancy, as before. Attention clears only when the surface actually receives focus (focused pane, current workspace, window focused). The visible focused tab therefore never shows a tint; an active tab in an unfocused pane or another workspace can hold attention and keeps its tint by the ladder above (needs-input / completion over active).

Don't stack the two edges (a 1px occupancy line inside a 2px NeedsInput line). One channel, one color — stacked edges read as a rendering bug at these widths.

Move source highlight

The mark — one dashed ring, three scopes

After Move Surface / Move Tab / Move Pane, the armed target carries a dashed 2px ring in --tasty-accent-move (pink) until the move runs, another target is armed, or the target closes. The ring is the same for all three kinds; only the rect changes — surface rect, tab cell rect, or the whole pane (tab strip + content). Dashed is the discriminator: every other edge in the product (notification, occupancy, focus, divider) is solid. Static, never animated, and it does not take input.

Layout spec
shape
dashed ring, 4 on / 4 off
weight
2px --tasty-focus-ring-width
placement
INSIDE the rect — never on the shared divider
surface
surface rect
tab
tab cell rect (24 × tab width)
pane
pane rect = tab strip + content
motion
none — static
input
pass-through
Tokens used
move-source-ring— ring (→ accent-move → pink)move-source-ring-width— 2pxmove-source-dash— dash 4move-source-dash-gap— gap 4

Same language, different rect. A pane ring encloses the tab strip, a surface ring does not — that alone tells the three apart, and the menu already names the kind in the toast. Why pink. Blue, yellow, green, peach, mauve, lavender and sky already mean completion, needs-input, occupancy, agent, attach and remote; pink is the one Catppuccin hue with no state attached, and it stays far from all of them at a 2px edge. Inside, not outside: split surfaces share a 1px divider, so an outside ring would paint over the neighbour.

Tab cell — ring over every tab state

The tab ring sits on top of the cell whatever its state. On the active tab the top edge crosses the 2px active indicator: pink dashes cover it, the blue shows through the gaps, so "active" stays readable from the panel fill and title color.

Layout spec
rect
tab cell, incl. its right separator
active indicator
dashes over it, gaps show it
title / icon
unchanged — attention tint still applies

Target not visible — move glyph on the nearest visible container

The target is often out of sight: the user arms a move, then goes to another workspace to pick the destination. The nearest visible container of the target shows the move glyph (12px, pink): the workspace row (and the rail avatar when collapsed) when the target lives in another workspace, or the inactive tab cell that holds a target surface in this workspace. Ring = this is it; glyph = it is in here. Only one cue is on screen at a time; once the target itself is visible, the glyph goes and the ring shows.

Layout spec
row
trailing, before the badge group
tab cell
after the title (title truncates)
rail
12px chip bottom-left — top-right = attention dot, bottom-right = mirror chip
text
none — glyph only, no i18n length
Tokens used
move-source-glyph— glyph (→ accent-move)move-source-glyph-size— 12 — row · tabmove-source-chip-size— 12 — rail chipmove-source-chip-glyph-size— 8 — glyph in chip

Don't ring a container. A ringed tab means "this tab moves"; a ringed workspace row would read as "this workspace moves", which is not an action.

Overlap — dashes on top, gaps show what is underneath

The move ring is painted last on the target rect, above the notification border, occupancy border, occupancy overlay and focus bed. It never replaces them: the dashes are pink, the gaps show the edge beneath. A move source that also needs input reads as a pink/yellow dashed band — both facts at once, no stacked edges, no priority to resolve.

Layout spec
z-order
focus bed < occupancy overlay < notification / occupancy edge < move ring
2px edge under
gaps show it at full width
1px edge under
gaps show 1px edge + 1px bed
clears
move runs · another target armed · target closes
kept on
focus change · tab switch · workspace switch

Not decided, deliberately left out. Dimming the source the way Explorer dims a cut file: rejected — the source stays fully usable while armed, and a dim collides with the unfocused-surface dim. Destination candidates while the menu is open: rejected — every other surface/tab/pane is a candidate, so marking them all carries no information, and the menu is native.

Loading overlay

Loading overlay — four placements over existing content (2026-10-16)

While a slow request runs (first: remote attach adding its structure) a LoadingOverlay is drawn on top of what is already there. The part itself adds no tab, pane, slot or "Loading…" title and never touches the stored name. (2026-10-19) This is the part's responsibility only: when the user asks for a new tab or split, the client first lays out a normal tab / split and the part is drawn on it — see Pre-layout below. Each placement has a fixed box (dashed below = the overlay rect): surface = the surface rect, spinner 16 on a 24 bed bottom-right · tab = the 14 kind-icon slot · LNB card = the 16 dot slot · rail = the 36 avatar, a 28 ring around the initial. Ink is one token everywhere. The overlay has no hit-test and no focus: click, drag, hover, close, select and keys behave as if it were not there. The caller passes the rect and visible; the part owns no timers.

Layout spec
surface
rect = surface · bed 24 bottom-right, inset 8 · spinner 16
tab
rect = kind-icon slot 14 · bed = cell fill · spinner 14
LNB card
rect = dot slot 16 × title line · bed = row fill · spinner 14
rail
rect = avatar 36 · no bed · ring 28, 2px
mask
none — no dim, no scrim, nothing outside the box
input
pointer-events none · never focused
lifetime
caller: visible + rect; no network/journal/timer state
geometry
unchanged on/off — tab count, widths, title, surface rect, PTY grid, LNB rows
Tokens used
loading-overlay-indicator— → spinner-indicator → accent-primaryloading-overlay-surface-bed-bg— → surface-raisedloading-overlay-surface-bed-border— → border-defaultloading-overlay-tab-size— → tab-icon-size 14loading-overlay-lnb-size— → icon-size-sm 14loading-overlay-rail-size— → size-28loading-overlay-surface-inset— → space-sm 8

Tab — the spinner takes the kind-icon slot, nothing else

The spinner covers only the 14 kind icon at the start of the cell, on a bed of the cell's current fill (inactive / hover / active). The label, its attention colour (needs-input, completion), the active indicator and the whole right cluster (HTML marker · move · busy · close) stay as they are; close still appears on hover. Loading (accent arc, left) and busy (green dot, right) can show together and do not read alike. The Ctrl switch keycap fills the same slot and draws above the spinner while the modifier is held. In a narrow tab the label still ellipsises first. A tab scrolled out of the strip is not drawn, so its spinner needs no repaint.

Layout spec
covers
kind icon only
bed
the cell's resolved fill (tab-bg / surface-hover / tab-bg-active)
kept
label · attention colour · indicator · marker · move · busy · close
busy + loading
both visible — arc left, green dot right
Ctrl keycap
above the spinner (same slot)
off-screen tab
not drawn · no repaint
Tokens used
loading-overlay-tab-size— → tab-icon-size 14

LNB card — the spinner takes the dot slot

On the expanded sidebar the spinner sits in the card's 16 dot slot (title-line height) over the status dot and, if present, the attached ring, on a bed of the row's current fill. Title, REMOTE pill, subtitle, description, notification badge, active accent bar and hover are untouched; the ring and dot come back as they are when loading ends. Only the loading card changes; no other card and no part of the LNB is covered. The Alt switch keycap draws above it. Works the same with categories on or off. A card inside a collapsed category is not drawn, and its category header (and rail --- button) shows nothing: it follows the existing collapse rule.

Layout spec
covers
status dot + attached ring (dot slot 16)
bed
bg-sidebar + the row fill (surface-active / overlay-hover)
kept
title · REMOTE · subtitle · description · badge · accent bar · hover
Alt keycap
above the spinner
categories
same on/off
collapsed category
card not drawn · header and rail --- show nothing
Tokens used
loading-overlay-lnb-size— → icon-size-sm 14workspace-dot-slot— → size-16 (rect)

Rail avatar — a ring around the initial

The four avatar corners are taken (TL refusal · TR attention dot · BL move · BR mirror) and the initial is the avatar's identity, so the spinner is a 28 ring around the initial with no bed. The initial stays readable inside it. Corner chips keep their 2px bg-sidebar halo and draw above the ring, so they cut it cleanly; the attached ring (square, lavender) sits outside it. Active, hover and the Ctrl+Alt switch keycap behave as today; the keycap draws above.

Layout spec
ring
28 · 2px · centred on the 36 avatar
initial
kept, inside the ring
corner chips
above the ring (halo cuts it)
attached ring
unchanged, outside
collapsed category
avatar not drawn · nothing on ---
Tokens used
loading-overlay-rail-size— → size-28

Surface — corner bed, content stays live

Inside a surface the spinner sits on a 24 bed bottom-right, 8 in from the rect, never outside it: other splits, panes, the tab strip and the LNB are not covered. There is no dim: terminal cells, cursor and selection keep drawing at 0ms and are never faded or moved; output keeps arriving under it. Bottom-right keeps clear of the search bar (top-right) and of a prompt at the bottom-left; if a long line runs under the bed, the bed wins for 24 × 24 only. The move-target ring stays on the rect edge outside the bed, and in-surface overlays (search bar, popups) draw above the spinner. On a native WebView surface nothing is drawn inside: the OS paints the page above Tasty and hiding the WebView for a non-blocking spinner is not acceptable, so the tab and LNB card carry the loading. If the native overlay layer lands later, the same bed applies there.

Layout spec
place
bottom-right · inset 8 · inside the rect
bed
24 · surface-raised · 1px border-default · radius 4
terminal
no dim, no motion on cells · PTY grid unchanged
z-order
content < move ring < spinner < search bar / popups
WebView
nothing in-surface; tab + LNB show it; never hide the WebView
Tokens used
loading-overlay-surface-size— → spinner-size 16loading-overlay-surface-bed-size— → size-24

All three at once · UI scale · reduced motion · timing

One request can light the rail avatar (or LNB card), the tab and the surface together; each is switched independently by its caller. The overlay appears only if the request is still running after --tasty-loading-overlay-delay (200), so fast round trips never flash; it appears and disappears without a fade (the arc is the motion) and goes the instant loading ends, showing the content as it is at that moment. Rotation is Spinner's 900ms period. With reduced motion it is a still arc on its track, not three dots: dots read as a typing or ellipsis mark and do not fit in a 14 slot or around an initial. Sizes are tokens, so they follow --tasty-ui-scale; stroke stays 2px at 1×.

Layout spec
switching
per placement, by its caller
delay
200 · loading-overlay-delay (same role as explorer-address-pending-delay)
in / out
no fade · gone immediately on finish or failure
rotation
spinner-duration 900
reduced motion
still arc + track (Spinner reducedMotion="arc")
scale
token sizes × ui-scale; stroke 2px at 1×
hidden placements
off-screen tab / collapsed card not drawn → no repaint
Tokens used
loading-overlay-delay— → motion-ui-fade 200spinner-duration— → duration-900

Labels and tooltips

Layout spec
loading.generic
en Loading… · ko 불러오는 중… · ja 読み込み中…
loading.remote_attach
en Attaching… · ko 연결하는 중… · ja 接続中…
tooltip
existing tooltip + second line (muted)
a11y
role=status on the overlay, label = same key

Don't write "Loading…" into the tab title or workspace name, and don't add a slot beside the title for it.

Pre-layout — the client draws a normal tab / split first, the overlay sits on it (2026-10-19)

Two different changes, shown apart. (a) Overlay toggle on a fixed target (the specimens above): geometry never changes. (b) Pre-layout: the user asks for a new tab, a surface split or a pane split on an attached workspace; on the next frame the client adds a normal tab / split — same strip, same split geometry, no loading layout, no new SurfaceKind — and puts the existing LoadingOverlay on the new tab's kind-icon slot, the new surface, and the workspace's LNB card. The spinner's 200 delay applies to the spinner only, never to the pre-layout. When the response arrives the same spot turns into the real content. The tab keeps its own title; "Loading…" is never written into it.

Layout spec
timing
pre-layout on the next frame after intent · spinner after loading-overlay-delay 200 · both off on response
new tab
normal tab appended after the current one · its own default title · overlay in the kind-icon slot · new surface overlay · LNB card dot slot
surface split
current tab's split geometry · overlay on the new side only · tab icon + LNB carry it too
pane split
layout split · new pane = independent tab strip + one tab + new surface
input
existing surfaces keep content, selection, input, scroll · the new area sends input nowhere until it is real · the user can move to any real area
WebView kind
tab + LNB only (unchanged rule); native overlay only when it really exists
not
no loading-only layout · no new SurfaceKind · no 'Loading…' title
Tokens used
loading-overlay-delay— spinner only — never delays the pre-layout

Pre-layout — selection moved · several adds · explicit failure · merged results (2026-10-19)

Selection moved: while waiting, the user picks another real tab; when the response lands, the new tab turns real in the background and the user's selection is kept. Several adds to the same pane: each request gets its own tab with its own overlay. Explicit failure: only that tab / split and its overlay go; everything the user did since stays; the reason goes through the existing error notice. Merged results that cannot be matched to requests: the confirmed structure is shown, pre-layout areas that might duplicate it are folded, the LNB card keeps its wait for unfinished requests, and nothing is auto-focused. This is an exception path, never the default for a single add.

Layout spec
selection
never taken from the user · real tab appears in place, unselected if the user moved
several
one pre-layout area per request, in request order
failure
remove that area + its overlay only · later user changes kept · existing error / notification
merged
show confirmed · fold possible duplicates · LNB wait stays for unfinished · no focus on an uncertain target

Response wait on an attached workspace — PendingIndicator (2026-10-19)

Requests that make no new area — close, move, restore closed, convert — show a small PendingIndicator in the window's top-right notice column (under the top-right pane's tab strip, 8 in; see Overlays · Popups › Notice column for its neighbours). Text is neutral (“Waiting for response”) plus the elapsed time; with several requests it counts them and shows the oldest wait. It is view-only: no cancel, no retry, no resend. It disappears on completion; failure and disconnect hand over to the existing error notice and reconnect banner. Unrelated output and other completions do not reset the time. It belongs to its workspace: switch away and it is not shown elsewhere; come back and it shows the running time. It never starts because a shell is quiet (password prompt, long command), and on an old server only supported requests are tracked.

Layout spec
use for
close · move · restore closed · convert (no new area) — never instead of a pre-layout
box
24 · pad-x 8 · gap 8 · surface-raised · 1px border-strong · radius 4
content
spinner 14 (loading-overlay-indicator) · caption 12 label · · · mono tabular time
copy
Waiting for response · 12s / Waiting for 3 responses · 1m 04s · ko 응답 대기 중 · 12초 / 응답 3건 대기 중 · 1분 04초 · ja 応答を待っています · 12秒 / 3件の応答を待っています · 1分04秒
time
<60 s → 12s · <1 h → 1m 04s · else 1h 02m · the OLDEST pending request
compact
column < 200 → spinner + time; label in aria-label / tooltip
tooltip
one line per pending kind with its own time · never internal ids
show / hide
after 200 (loading-overlay-delay) · gone on completion · failure → existing error · disconnect → reconnect banner
scope
per workspace · hidden while another workspace is shown
never
scrim · full-width bar · cancel / retry / resend · started by shell silence
Tokens used
pending-indicator-height— → size-24pending-indicator-padding-x— → space-sm 8pending-indicator-gap— → space-sm 8pending-indicator-bg— → surface-raisedpending-indicator-border— → border-strongpending-indicator-fg— → text-secondary-raisedpending-indicator-time-fg— → text-primarypending-indicator-sep-fg— → text-mutedpending-indicator-spinner-size— → icon-size-sm 14pending-indicator-compact-below— → size-200pending-indicator-delay— → loading-overlay-delay 200

Contrast on surface-raised: label text-secondary-raised Mocha ≈ 7.1:1 · Latte n1100 5.17:1 · time text-primary Mocha ≈ 9.5:1 · Latte 5.17:1 · separator text-muted is decorative · spinner arc accent-primary Mocha ≈ 6.9:1 · Latte ≈ 3.6:1 (≥ 3). Chip edge border-strong is the same floating edge as Toast / Banner.

Don't start the wait because a terminal has been silent, and don't use the chip in place of the pre-layout for a new tab or split.

Notifications summary (LNB · rail)

LNB summary — expanded sidebar (2026-10-17)

A small block directly above the Tools · Plugins · Settings block, with its own top rule. The whole block is one button: click (or the existing notifications shortcut) toggles the popup. Line 1: bell · Notifications · one count pill per level with unread items, fixed order error › warning › info. Line 2 (only when something is unread): the newest unread title, one line, ellipsis, with its level glyph and elapsed time. With nothing unread the block is one muted line. Workspace rows get no new badge; their blue count stays the surface-attention count.

Layout spec
position
above the Tools block · own 1px top rule · pad 8
line 1
24 · bell 16 · label 13 · badges right, gap 4
line 2
unread > 0 only · newest unread title 12 · glyph 12 muted · time mono 10
empty
one line · label + bell in text-muted · no pills
click
toggles the popup (= existing shortcut)
hover / open
overlay-hover / overlay-active bed
arrival
counts + line 2 update · popup never opens · focus kept
l10n
label ellipsises first; pills never shrink
Tokens used
notification-summary-pad— → space-sm 8notification-summary-row-height— → size-24notification-summary-label-fg— → text-secondarynotification-summary-label-fg-empty— → text-mutednotification-summary-preview-fg— → text-secondarynotification-summary-bg-hover— → overlay-hovernotification-summary-bg-open— → overlay-active

Don't add the notification counts to workspace rows or rail avatars, and don't sum them with the surface completion / needs-input counts.

Rail — bell with stacked counts

The collapsed rail keeps the same block above Tools, as a 44-wide button (wider than the 36 avatar, so 99+ fits with its glyph). Bell on top, then the level pills stacked error › warning › info, zeros skipped. With nothing unread it is the bell alone at button height. No line 2; the tooltip carries the full counts.

Layout spec
box
44 wide · min 28 · pad 4 when counts show
stack
bell · pills column, gap 2 · error › warning › info
0
pill not drawn · all 0 = bell only
cap
99+ (fits 44 with glyph)
tooltip
Notifications — 2 errors · 1 warning · 5 info
Tokens used
notification-rail-width— → size-44notification-badge-stack-gap— → size-2

Level count badge — NotificationBadge

A 16 pill with the level glyph and the count, so the level reads without colour: error red (critical failure) · warning yellow (non-critical failure) · info the completion blue. Each pill has an aria-label / tooltip ("3 errors"). 0 draws nothing; above 99 reads 99+.

Layout spec
size
16 high · min 16 · pad-x 4 · pill radius
glyph
10 · alertCircle / alertTriangle / info · gap 2
number
mono 10 bold
order
error › warning › info
0
not drawn
cap
> 99 → 99+
Tokens used
notification-badge-error-bg— → notify-error → accent-dangernotification-badge-warning-bg— → notify-warning-fill → Mocha notify-warning · Latte yellow 50% over white (2026-10-19)notification-badge-warning-fg— → notify-warning-fg → Mocha text-on-accent · Latte neutral-1100 — Latte 5.01:1 (was 2.62)notification-badge-info-bg— → notify-info → attention-completionnotification-badge-error/info-fg— → notify-*-fg → text-on-accentnotification-badge-glyph-size— → size-10notification-badge-max— 99

Workspace status bar

What the 24px bar carries, and what goes first when it can't

The bar is a read-only summary of the focused surface plus one keyboard reminder. Left cluster, in this order: git branch (glyph + name), surface id (mono s3·p1), shell, grid (120×32). Right cluster: the palette shortcut and the theme glyph. Nothing here is a destination — clicking an item never moves focus, and the data is read from the focused surface at the existing 1Hz cache. Items that have no value disappear rather than showing a dash: outside a repo the branch item is absent, not empty.

Layout spec
height
24 — --tasty-status-bar-height, outside the UI scale
left
branch · surface id · shell · grid
right
palette shortcut · theme glyph
glyphs
--tasty-statusbar-glyph
theme
a sun / theme glyph, not a coloured dot
drop order
1 grid → 2 shell → 3 surface id → 4 palette cap → 5 branch text (glyph stays)
never drops
theme glyph
no value
the item is absent — never a dash
detached HEAD
short sha in the branch slot
interaction
read-only; no item moves focus
refresh
1Hz cache — unchanged
Tokens used
status-bar-height— 24px barstatusbar-glyph— item glyphsstatusbar-theme-glyph— theme indicatorstatusbar-dot-size— 6 — when a surface dot is shown

The theme indicator used to borrow accent-warning for light and accent-agent for dark. Light-vs-dark is not a warning and not an agent, so it becomes a glyph in dim ink — no colour role is minted for it.

Don't add counts, clocks, CPU meters or notification badges. Anything that changes faster than the bar's 1Hz read, or that is not a property of the focused surface, belongs elsewhere.

Theme cell — one glyph box, both themes (settled)

The theme cell draws sun in Latte and theme in Mocha at one size: --tasty-statusbar-glyph-size = --tasty-icon-size-xs (12), the same box every other inline glyph in the bar uses. A dimension must not depend on which theme is active, so the two glyphs are not given different sizes. They used to read differently anyway — sun's rays ran to the edge of the 24 box (ink 92%) while theme's ring stops short (ink 83%), so Latte's glyph measured +2px taller than the caption beside it at every UI scale. That is a drawing fault, not a sizing one, so it is fixed in the asset: icons/sun.svg ray extent pulled to the same 83% ink box. Both glyphs now land at 10px ink inside the 12 box, matching the 11px caption's ascender.

Layout spec
glyph box
12 — --tasty-statusbar-glyph-size → --tasty-icon-size-xs
per theme
the same value — Mocha and Latte never differ
per glyph
the same value — sun and theme never differ
UI scale
rides the scale with the rest: round(12×s) = 10 / 12 / 14
bar height
24, outside the UI scale — unchanged
ink ratio
83% of the box for both glyphs (10px ink at box 12)
optical fix
asset — icons/sun.svg rays 2→3 / 22→21, diagonals to ±6.4
ink before
sun 92% · theme 83% → Latte read +2px large
color
--tasty-statusbar-theme-glyph
Tokens used
statusbar-glyph-size— every inline glyph in the baricon-size-xs— the semantic it aliasesstatusbar-theme-glyph— theme glyph inkstatusbar-dot-size— 6 — the dot on the same line

The two glyphs sit side by side here only to compare them. In the product the cell carries exactly one: sun in Latte, theme in Mocha, with the theme name beside it.

Don't give sun its own size token to cancel the overshoot. A size that changes with theme state turns a dimension into a state, and the same overshoot would follow sun into every other place it is drawn. Optical parity belongs in the glyph.