tasty.gallery
Overlays · Dialogs
← Design
Specs
Theme

Dialogs

Explore dialogs for confirming an action or editing a value. Each example shows the message, available actions, and how the dialog closes.

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

Scrim & frame — the shared recipe

Every overlay sits on the same scrim

Modals are the one place the system uses shadow. The recipe: a 50% black scrim (+1px blur) over the app, a frame of --tasty-bg-panel (or --tasty-surface-raised for the palette) with a 1px --tasty-border-strong edge and a soft drop shadow. Click-scrim or Esc dismisses.

Layout spec
scrim
rgba(0,0,0,.5) + blur(1px)
frame fill
--tasty-bg-panel
border
1px --tasty-border-strong
shadow
0 20px 60px / .55 (modals only)
dismiss
scrim click · Esc
Tokens used
bg-panel— frameborder-strong— edgeradius— 4px corners

The two anchor positions: centered (dialogs, settings) and top-aligned (command palette, ~40px from top). Nothing else.

Scope — a surface-bound popup dims its surface, not the window

A popup that declares surface scope is centered inside one surface, so its scrim covers that surface's rect and nothing else. Dimming the whole window says “the application is blocked” when only one pane is; the sidebar, titlebar, status bar, the divider and the adjacent surface stay at full ink and keep reading as live. Window-scope popups (command palette, settings, plugins, every centered confirm) keep the full-window scrim — the choice follows the popup's declared scope, not its shape.

Layout spec
scrim rect
the surface's OUTER rect (its own border included)
excluded
divider · pane tab strip · sidebar · titlebar · status bar · adjacent surfaces
radius
follows the surface's own radius (0 in the shell today)
clip
scrim AND the popup's shadow are clipped to the rect
popup inset
8px, so the shadow has room inside the clip
fill
--tasty-scrim-bg
window scope
unchanged — full-window scrim
input blocking
unchanged — dim ≠ block
Tokens used
scrim-bg— 50% black, both scopesshadow-modal— the popup keeps its modal shadowborder-frame— divider / surface edge, undimmed

The alpha does not change: one scrim value, two scopes. And the surface is still a centered, scrim-backed surface, so it keeps shadow-modal — narrowing the scrim does not make it a popover.

Don't read this as an input rule. The scrim marks what the popup belongs to; which input it swallows is the host's existing contract and is unchanged here.

Both split directions, a clamped popup, and a child picker

The boundary has to read in every arrangement. Side-by-side and stacked splits show the divider staying lit between a dimmed and an undimmed surface. In a narrow surface the popup clamps to the rect (it never grows past its surface, and focus is never moved to win space). When a child picker opens from the popup, the scrim is drawn once — the parent shell stays fully painted under the child, which sits above it with its own shadow. Two shells, one scrim, no accumulated darkness.

Layout spec
scrim count
one per scope — never composited twice
depth
scrim z0 · parent shell z1 · child shell z2
parent under child
full ink, not dimmed further
clamp
popup shrinks to the surface rect, min 320 → surface width wins
surface hidden
popup shell AND scrim hide with it; both return on re-show
host popups
same rule when they declare a surface scope
Tokens used
fp-popup-min-width— picker floor, surface overrides itscrim-bg— drawn onceshadow-modal— both shells

convert_surface and any other host popup bound to a surface gain this scrim (they have none today); host popups with no target binding stay on the window scrim. Child-lifetime and draft policy are the host's, unchanged by this visual rule.

Agent approval

Approval popup — the mauve gate

Shown when an agent requests a privileged or destructive action. The mauve agent accent in the header and tag makes it unmistakable. Show the exact command, the requesting plugin, the target surface ID, and the permission tags. Three responses: deny / allow once / always.

Layout spec
width
440px
header dot
--tasty-accent-agent
command
mono on #000
actions
ghost / secondary / agent
Tokens used
accent-agent— agent gateaccent-danger— destructive tagbg-panel— frame

Do name the actor and show the literal command. The user is granting authority — make the stakes legible.

Danger choice — tinted fill with readable ink (2026-10-10 b12)

An approval request can offer a danger choice (the option that does the destructive thing). It was painted as accent-danger × 0.18 in code, which fell under 4.5:1 (Mocha 3.18 today, Latte 3.92 once mixed in sRGB). It now has tokens: an opaque fill --tasty-approval-danger-bg = danger at the tint-fill 12% mixed into bg-panel (not into whatever is behind), a 1px --tasty-approval-danger-border in danger so the tone survives without colour-only ink, and text-primary label ink (--tasty-approval-danger-fg). The ink stays text-primary rather than danger red: red on the tint is 3.94 in Latte.

Layout spec
fill
color-mix(in srgb, accent-danger 12%, bg-panel) — opaque · Mocha (56,43,61) · Latte (236,214,222)
label
text-primary · Mocha 9.18 · Latte 5.80
edge
1px accent-danger · vs surface-raised Mocha 5.43 · Latte 3.52 (≥ 3 non-text)
retired
linear_multiply(0.18) in code
hover
the normal button hover overlay on top
Tokens used
approval-danger-bg— fillapproval-danger-border— → accent-dangerapproval-danger-fg— → text-primary

Contrast is WCAG 2.x relative luminance on the computed sRGB values. Choice labels in the sample are illustrative.

Convert surface

Convert popup — pick the kind to become (2026-09-29)

A small list popup that converts a surface to another kind without losing the running process or scrollback — only the renderer changes. The shared popup title bar, then one MenuItem per kind the surface can become; click (or ↵ on the highlighted row) converts, × / Esc close. This is the product's shape and the target — the earlier 400-wide From / To dialog is retired. Width is --tasty-convert-popup-width (240, was a literal 200) and scales with ui_scale like every popup width; height = title bar + rows.

Layout spec
width
240 × ui_scale · --tasty-convert-popup-width (was literal 200)
title
shared PopupTitleBar (× only, reserve 32 per side)
rows
MenuItem 28 · icon + kind label · one per convertible kind
actions
click / ↵ converts · × / Esc close
Tokens used
convert-popup-width— → size-240, × ui_scalebg-panel— frameborder-frame— edge

Shortcut chip — each row's letter as a trailing keycap (2026-10-12)

While the popup is open, one bare letter converts to that row. The letter is the kind id's first latin letter, upper-cased (terminal T · markdown M · image I · html H · explorer E · dag D), so it never follows the translated label — the row shows it. It rides the existing MenuItem shortcut slot as the DS Kbd keycap, the same one the command palette uses: no new tokens. Display only; it takes no clicks. The keycap is the same in every row state — it already sits on its own raised fill, so it reads on rest, hover and active alike. A row whose letter collides with an earlier row has no shortcut: no chip, no reserved slot, the label runs to the end. The label ellipsises; the chip never shrinks. Width stays --tasty-convert-popup-width 240.

Layout spec
chip
DS Kbd keycap in MenuItem shortcut slot · one latin capital
letter
kind id first letter, upper-case · never the label
states
rest = hover = active · keycap unchanged
no shortcut
collision → no chip, no reserved slot, label to row end
overflow
label ellipsis · chip flex none
width
240 × ui_scale — unchanged
input
display only · click / ↑↓ ↵ / letter / Esc unchanged
Tokens used
kbd-size— keycap height / min widthkbd-font-size— lettersurface-raised— keycap fillborder-strong— keycap edgetext-secondary— letter ink

Shared rule for any "pick by one letter" list popup: show the letter as a trailing Kbd keycap in the MenuItem shortcut slot; rows without a letter drop the chip. Scope here is the convert popup only.

Narrow popup, long title — width scales, cut titles get a tooltip (2026-09-29)

Every popup width is a token and is multiplied by ui_scale, the same as the title and the reserve. Scaling alone keeps the ratio, so the base width must fit the longest locale title at 1.0: the ja title サーフェスタイプ切替 is ≈ 139, and 200 leaves a band of 200 − 2 × 32 = 136 — it cuts at every scale. The convert popup's base therefore goes to 240 (band 176), and at 1.2 everything scales together (288 / 211 / ≈ 167). A title that still overflows ellipsises inside the symmetric band (unchanged), and only then shows the full title in a Tooltip on hover (shared placement: top, then bottom). No content-driven widening: the width stays a token.

Layout spec
width
token × ui_scale — never a literal · base sized for the longest locale title at 1.0
band
width − 2 × reserve(N) · convert 240 → 176
overflow
ellipsis inside the band
tooltip
only when the title is cut · full title · top → bottom
widen to fit
no
Tokens used
convert-popup-width— 240 × ui_scalepopup-title-btn-size— reserve part

Don't keep a popup width as a literal outside the scale, and don't widen a popup to its title — the band follows the token width.

File handler picker

Open with… — pick a handler

Shown when a file can be opened more than one way. A list of handlers — built-in (preview / editor / pager) and plugin-contributed — each with an icon, name, and origin. The header carries the path and the detected format; the current default is tagged. Selected row uses the accent left-bar pattern.

Layout spec
width
420px
header
title + format Tag · path (mono 11)
rows
icon · name · origin
default
Tag accent
selected
2px accent left bar
footer
Cancel / Open — settled F1 (b), see “Footer — settled”
Tokens used
surface-active— selectedaccent-primary— select baraccent-agent— plugin handler

Plugin handlers are marked with the mauve agent accent — you always know a third party is handling the open.

Detected format — one Tag in the header

The picker always says what it thinks the file is. The detector value (markdown · html · $directory) sits as an accent Tag on the title row, opposite the title; the path sits under it in mono. When detection fails the Tag reads “format unknown” in the neutral outline variant — an unknown format is a fact, not an error.

Layout spec
place
title row, right-aligned
detected
Tag accent
unknown
Tag default (outline)
path
mono 11 · --tasty-text-muted
long path
elide at the FRONT in the model, then render LTR
Tokens used
accent-primary— format Tagfont-mono— pathtext-muted— path ink

Do cut long paths at the front — the filename is the tail and the tail identifies the file. Elide in the model (drop leading segments, prefix …/) and render the result left-to-right. Don't reach for direction: rtl + text-overflow: it reorders the run and clips the informative end.

Recent — a second group in the same list

The picker shows two kinds of handler: Suggested (matched to this format) and Recent (the user's last picks, all formats, LRU cap 10). They live in one 420px list separated by a rule and a group label, not in two columns — at 420px a column can't hold icon + name + origin. The Recent group carries a one-line caption saying it is not matched to this format, and its rows show when they were used instead of a bare origin. Selection is single across both groups.

Layout spec
groups
Suggested → rule → Recent
group label
11px uppercase, +0.06em, text-secondary
count
mono, next to the label
caption
11px muted, one line
recent meta
origin · id · when
when column
reserved --tasty-fh-when-width (56) — never shrinks, never drops
selection
one row across BOTH groups
Tokens used
separator— group ruletext-secondary— group labeltext-muted— caption / timefh-when-width— when column reserve

Don't split the frame into two side-by-side lists. Two 200px columns lose the origin line, and the eye has to compare across a gutter to pick one thing.

Recent — the relative-time vocabulary (settled T1–T4)

The when fragment is six buckets and nothing else. Under a minute it says so; under a day it counts minutes then hours; the second day is a word; up to a week it counts days; past a week it stops counting and shows the date. A 23d ago does not help anyone pick a handler, and the ISO-style date needs no ko/ja string and no plural rule. The column is reserved at the widest member (the date, 10 mono chars) so the popup — which recomputes the bucket every frame — never reflows the id when a row crosses a boundary while open. When the line is short of room the id yields: it already elides at the front; a partially clipped time would read as a different time.

Layout spec
T1 buckets
6 — the proposed 5 plus a 7-day ceiling
T2 en
approved as proposed for the first five
T3 ceiling
≥ 7 d → YYYY-MM-DD, never {n}w / {n}mo
T4 priority
when never shrinks or drops; the id elides first
reserve
--tasty-fh-when-width = 56 — 10 D2Coding chars @ 11px (55) up to the grid
live update
allowed — the reserve makes a bucket change reflow-free
ko / ja
translate the five words / patterns; the date bucket has no string
n
integer floor; 1m / 1h / 1d singular forms are the same pattern
Tokens used
fh-when-width— when columntext-muted— when ink

Don't freeze the value at open time to avoid churn. The reserve already makes churn invisible to layout, and a picker left open across midnight should say yesterday.

Header path — where the front cut lands (settled T5)

The header's path line is one mono-11 row inside the 420 frame: 390px after the 1px frame borders and the 14px insets (420 − 2 − 28), which is 65 characters at this font: D2Coding 11px is 5.56px nominal, but each glyph advances a whole 6px once rounded, so 390 / 6 = 65. The cut is a measure, not a count: drop whole leading segments and prefix …/ until the remainder fits the line box. The 65-char figure is the derived cap for a path that cannot be measured. The one time a cut falls mid-segment is a single segment longer than the whole line — then the filename itself elides at the front, by characters. The 48-char provisional cap was cutting paths that fit by a wide margin.

Layout spec
line box
390 = 420 − 1 × 2 (frame border) − 14 × 2
cap
65 mono chars @ 11px (390 / 6 — rounded advance, D2Coding)
was
70 / 5.5 nominal — overran the line by ~30px (2026-10-06)
rule
measure first; the count is the fallback
boundary
segment — drop whole leading segments, prefix …/
mid-segment
only when one segment exceeds the line; then front-elide by characters, prefix …
row id
unchanged — 34 chars, front, the id line is narrower
was
48 chars provisional — retired
Tokens used
font-mono— pathtext-muted— path ink

No suggestions — the whole catalog, one time only

When nothing matches, the list falls back to All handlers — same rows, different promise. Three signals carry the difference: the group label reads All handlers in the attention tone with the caption “No handler matches this format.”, the header Tag reads format unknown, and a strip under the header states the consequence — the pick is one-time and this screen returns next time. Distinct from the empty state: here there is something to choose.

Layout spec
label
All handlers · --tasty-accent-attention
strip
8/14 on --tasty-surface-raised
strip icon
alertTriangle, attention
promise
one-time dispatch, no registration
Tokens used
accent-attention— fallback tonesurface-raised— notice striptext-secondary— strip copy

Attention-peach, not warning-yellow: nothing is wrong, the system just has no opinion.

Empty — nothing to pick from

No handler is registered anywhere in the system. The list area is replaced by a centered block — muted file glyph, “No handlers registered.”, one line of instruction, and a secondary button into Settings › Handlers. Open stays disabled; Cancel still works. This is the only state where the frame offers an exit to somewhere else.

Layout spec
block
32/14 padding, centered, 8px gap
glyph
--tasty-text-disabled
action
Button secondary sm → Settings › Handlers
Open
disabled
Tokens used
text-disabled— empty glyphtext-muted— instruction

Do keep the frame the same width and the footer intact — the empty state is the same dialog, not a different one.

Long list — cap the height, show the cut

Past ten rows the list scrolls inside a 264px area (≈8 rows) so the frame never outgrows a short window: header + 264 + footer ≈ 380px. Two affordances mark it — the count next to the group label tells you how many there are, and a 20px fade to the panel fill at the bottom edge shows the list continues. No scroll shadow, no custom scrollbar.

Layout spec
list max-height
264px (~8 rows)
frame max
≈380px total
overflow
auto, list area only
fade
20px → --tasty-bg-panel
count
mono, in the group label
Tokens used
bg-panel— fade targetseparator— header / footer rules

Header and footer never scroll — the path, the format, and the actions stay put.

One header, not two

The picker is headless (no common popup titlebar): the frame draws its own header so the path appears once, in mono, truncated from the front. Using the shared titlebar puts the path in two places at two truncations — the reading below. Same precedent as Transfer progress.

Layout spec
mode
headless: true
header owner
the frame
dismiss
Esc · Cancel · titlebar X — not scrim click
precedent
Transfer progress
Tokens used
bg-sidebar— titlebar fill (the rejected option)separator— header rule

Don't pair the common titlebar with an in-frame header. The path gets cut two different ways in one dialog.

Footer — settled: Cancel / Open, nothing else

The footer's “Always open .md with this” checkbox is dropped (reading b). The picker is a pure dispatcher: it opens one file, one time, and stores nothing. A checkbox that writes a persistent format→handler binding needs a place to see and undo that binding — Settings › Handlers has no such row today — and a checkbox that writes nothing is a lie. Every specimen on this page now carries the two-button footer.

Layout spec
footer
Cancel (ghost) · Open (primary)
footer height
44px (8/14 padding, 28px controls)
persistence
none — dispatch is one-time
default binding
lives in Settings › Handlers, set there
Open
disabled until a row is selected
Tokens used
separator— footer ruleaccent-primary— Open

If a persistent “always open with” ever ships, it belongs in Settings › Handlers as a visible, removable row — not as a checkbox on a transient dialog.

Rows — the icon and the name are derived, not stored

The handler model is { id, detector, priority, owner, action, disabled } — no icon field and no display-name field. Both are derived, and both stay legible when a plugin declares neither. Icon: the glyph of the surface kind the action opens (markdown → markdown, editor → edit, pager → terminal, directory → folder, binary → layers, table → columns), falling back to file for an unknown kind — a plugin-declared icon wins only if it names a glyph in icons.json. Name: the id's local segment after the last /, rendered in mono when it is the raw id and in the UI font when a name was declared, so you can see which you are reading. The second line always carries origin + the full id, front-elided — the id appears exactly once per row.

Layout spec
icon
from action surface kind → icons.json
icon fallback
file
name
id segment after the last “/”
name (raw id)
mono — signals “no declared name”
id line
origin · id (mono 11), front-elided at 34 chars
origin words
built-in · you · plugin
plugin
mauve --tasty-accent-agent on the origin word + glyph
Tokens used
accent-agent— plugin originfont-mono— id + undeclared namestext-muted— id line

Do elide a long id at the front. net.example.enterprise.documents… repeats across a vendor's handlers; the tail is what tells them apart — the same rule the header path uses.

The mauve stays on the origin word and the glyph, not on the name — a plugin's handler is still named after what it does.

Default Tag, and what the picker means now

The picker no longer appears when one handler clearly wins — the implementation dispatches the top match directly. So it opens in three cases only: ambiguous candidates, no match, or an explicit “Open with…”. The default Tag is kept for the first and third: in an ambiguous list it names the handler that would have run, and on an explicit open it tells the user what they are overriding. In the fallback state nothing is default, so no Tag appears. Interaction contract below.

Layout spec
single click
select, frame stays
double click
select + open
no selection
Open disabled
selection scope
one row across all groups
dismiss
Esc · Cancel · titlebar X
scrim click
does NOT close
Tokens used
accent-primary— default Tag filltext-on-accent— default Tag ink

Keep the Tag. It answers “what happens if I just press Enter” — the question the picker exists to ask.

Apply preset

Apply preset — scope then pick

Applies a saved layout preset at one of three scopes — Workspace / Tab / Pane — chosen with a segmented control in the header. Below it, a selectable list of presets with a mono summary of what each lays out. The confirm button names the active scope (“Apply to workspace”).

Layout spec
width
440px
scope
Workspace · Tab · Pane (segmented)
rows
name + mono summary
confirm
names the scope
Tokens used
accent-primary— active scope / selectsurface-active— chosen presetfont-mono— layout summary

Markdown open

Markdown open — preview vs raw

A two-choice confirm shown when opening a .md file: rendered preview or raw text in the editor. Two equal card-buttons with the default pre-selected (accent ring), filename in mono, cancel + confirm. The pattern for any “open as A or B” fork.

Layout spec
width
420px
choices
2 equal card-buttons
default
accent ring + fill
confirm
names the choice
Tokens used
accent-primary— selected choicesurface-raised— choice fillborder-default— unselected edge

Rename popup

Rename — the smallest dialog

A focused single-field dialog for workspace / tab rename. Title, a one-line keyboard hint, an autofocused Input, cancel/confirm. The template for any "edit one value" modal.

Layout spec
width
360px
field
autofocus, full-width
confirm
↵
padding
--tasty-space-md
Tokens used
bg-panel— frameaccent-primary— confirmborder-focus— field ring

Workspace category

New / rename category — the Rename dialog, reused

Creating and renaming a workspace category reuses the 360px single-field Rename dialog verbatim — title, keyboard hint, one autofocused Input, cancel/confirm. Name validation runs on confirm and shows an inline error under the field (the confirm button disables): empty after trim, the reserved word “normal”, or a case-insensitive duplicate.

Layout spec
width
360px (Rename dialog)
field
autofocus, full-width
confirm
↵
errors
empty · reserved ‘normal’ · duplicate
invalid
inline msg + confirm disabled
Tokens used
bg-panel— frameaccent-primary— confirmaccent-danger— error field + text

Error copy maps to the existing keys workspace_category.error.empty / reserved / duplicate. No new dialog chrome — this is the Rename template with a validation line.

Delete category — destructive confirm

“Delete category” never deletes immediately — a small centered confirm on the scrim gates it. The action is destructive (danger button + danger glyph). The body states the safe outcome: the category's workspaces are not deleted; they move back to normal (“Workspaces”). Small-modal tone, matching the Rename dialog footprint.

Layout spec
width
380px
trigger
Delete category (menu/popup)
tone
destructive — --tasty-accent-danger
body
workspaces move to normal (not deleted)
actions
Cancel / Delete category
Tokens used
accent-danger— glyph + confirm buttonbg-panel— frametext-on-accent— button label

Do reassure in the body: reversible-feeling copy (“workspaces move back to Workspaces”) lowers the stakes of an irreversible-sounding verb.

Remote transfer

Transfer progress — auto-closing status popup

Shown while a file is being received from an attached remote workspace over the bulk channel (begin/chunk/commit). A PopupDef frame on the shared scrim, headless header: download glyph · Receiving file · a right-aligned mono percent. Body: the filename (mono, middle of the frame, ellipsized), the system's first determinate progress bar — a recessed 4px track with an accent fill, no animation (the fill tracks bytes; motion stays 0ms) — and a mono stats line: transferred / total left, rate right. It auto-closes on completion; the single ghost Cancel aborts the transfer. It does not close on outside click — dismissing progress by mis-click would read as an abort.

Layout spec
width
--tasty-transfer-popup-width
header
download glyph · title · mono %
filename
mono 13 · ellipsized
bar
4px track + accent fill · no animation
stats
done / total · rate (mono, muted)
close
auto on completion · Cancel aborts
outside click
does not dismiss
Tokens used
progress-track-bg— recessed trackprogress-fill-bg— determinate fillprogress-height— 4px thicknessbg-panel— frame
Layout spec
scale (2026-10-06)
ON-SCALE: width AND every inset below multiply by ui_scale
header
pad-y transfer-header-pad-y 12 · pad-x transfer-pad-x 14 · gap space-sm
body
padding transfer-pad-x 14 · block gap transfer-body-gap 10
footer
pad-y transfer-footer-pad-y 10 · pad-x 14
reason well
transfer-well-pad-y 8 · transfer-well-pad-x 10
line heights
not tokens — each line = its font-size × line-height-ui (header row = max(glyph md, title line))
Tokens used
transfer-pad-x— 14 insettransfer-header-pad-y— → space-md 12transfer-body-gap— → size-10transfer-footer-pad-y— → size-10transfer-well-pad-y— → space-sm 8transfer-well-pad-x— → size-10

Rate / time-remaining is optional — filename + progress are the contract; drop the right stat when the channel can't estimate it. Multi-file transfers reuse this frame with one filename+bar row per file, newest last — same header, one Cancel for the batch.

Do keep the bar still — the fill moves only when bytes land. A shimmering or eased bar would be the only animated chrome in a 0ms-motion terminal.

Transfer failed — rejected & mid-transfer error

Shown when a transfer is rejected before it starts (capacity exceeded) or fails mid-transfer (channel dropped). Danger glyph + Transfer failed title; the body names the file (mono) and quotes the reason verbatim in a mono well — the same command-well pattern as agent approval, so the machine-reported cause is legible and copyable. Rejected → a single Dismiss; mid-transfer failure → Retry (secondary) beside a ghost Dismiss — retry is only offered when re-sending can succeed.

Layout spec
width
--tasty-transfer-popup-width
header
danger glyph · Transfer failed
reason
verbatim, mono well on bg-app
rejected
Dismiss only
failed
Dismiss (ghost) · Retry (secondary)
dismiss
button · Esc · scrim click
Tokens used
accent-danger— glyph + reason textbg-app— reason wellbg-panel— frame

Don't use a danger-filled button here — danger fill is reserved for confirming a destructive act (delete). This popup only acknowledges one; Dismiss stays secondary/ghost.

Capacity rejection points at its own cure: the reason names the configured limit, which lives in Settings › General › Remote transfer (Overlays › Windows).

Info modal shell — notice queue

One shell for every queued message

Boot and runtime notices share one queue and one shell, shown one at a time. The shell rules apply to every message: 440 wide, 140–360 tall; the title sits in the popup title bar (fixed strip, 14 / 600, 1px rule below, always drawn); body 13 · text-secondary, paragraph gap 12, scrolls when it overflows; a 1px scroll edge above the button row only while content is hidden below; buttons are the DS Button, right-aligned, dismiss = Primary, rightmost. Bodies are plain text — emphasis (paths, lead-ins, command chip) is authored by the permissions notice only. Below: a one-sentence message and the one message that ends the app.

Layout spec
size
440 × 140..360
title
popup title bar · 14 / 600 · rule below
body
13 · text-secondary · para gap 12 · plain
scroll edge
above buttons, only while content is hidden below
dismiss
Primary, rightmost · Enter / Esc
app-ending
same Primary, label Quit
Tokens used
info-modal-min-height— → size-140info-modal-title-edge— → border-defaultinfo-modal-scroll-edge— → border-default

App-ending message. The database error keeps the single Primary button — it is the only way forward, not a destructive choice, so Danger would suggest an alternative that does not exist. What changes is the label: Quit (ko 종료 · ja 終了) instead of OK, so the button says what it does. Enter / Esc still trigger it. The body text shown is a sample of the seven cause strings.

Popup title bar — one or two buttons, title centred on the strip (2026-09-29)

The shared title bar keeps the title centred on the strip however many buttons sit on the right. Both sides reserve the same width: edge inset + N × button + (N − 1) × gap + text gap — 32 for × alone, 60 for fullscreen + ×. A long title ellipsises inside that symmetric band; it never slides left to use the free space. Buttons are IconButton sm (--tasty-popup-title-btn-size 24; the product's 20 goes), --tasty-popup-title-btn-gap 4 apart, the rightmost --tasty-popup-title-edge-inset 4 from the edge. Fullscreen (glyph fit) sits left of × and appears only on a popup that declares a fullscreen stage — today the notifications popup (352 × 400). Hover = overlay + tooltip; click widens the popup to its stage.

Layout spec
centre
strip centre, always
reserve
4 + N × 24 + (N − 1) × 4 + 4 → 32 · 60 per side
buttons
IconButton sm 24 (was 20 in the product)
order
fullscreen (fit) · close
fullscreen
only when the popup declares a stage
long title
ellipsis inside the band
Tokens used
popup-title-btn-size— → icon-button-size-sm 24popup-title-btn-gap— → space-xs 4popup-title-edge-inset— → space-xs 4popup-title-text-gap— → space-xs 4notifications-popup-width— → size-352

Don't centre the title in the space left of the buttons. With two buttons the title then sits 28 px left of centre, and two popups side by side no longer line up.

Permissions notice (macOS) — the long case

The same shell with the longest body. The six paragraphs are kept (they are the only place the signing and Automation caveats appear) and the body scrolls; title bar and button row never do. The scroll edge (--tasty-info-modal-scroll-edge) sits above the button row at the top and mid-way, and goes away at the end; the title bar rule bounds the top in every state. Paragraph gap 12. Settings paths are set in text-primary / medium; the shell command is mono on a raised chip; the two lead-ins are semibold. Buttons: [Open permission settings] Secondary, [OK] Primary on the far right. Open keeps the modal up (settings is its own window). OK · Enter · Esc close. There is no "don't show again".

Layout spec
width
440 (unchanged)
height
140..360 · body scrolls
title
popup title bar · 14 / 600
body
13 · text-secondary · para gap 12
paths
text-primary · 500
command
mono 11 · surface-raised chip
scroll edge
1px border-default above buttons, only while content is hidden below
buttons
Open permission settings (Secondary) · OK (Primary, rightmost)
keys
Enter / Esc = OK
Tokens used
info-modal-width— → size-440info-modal-max-height— → size-360info-modal-para-gap— → space-md 12info-modal-scroll-edge— → border-default

ko / ja bodies are longer and only scroll further. The shell does not grow for them.

Permissions notice — FDA branches · signing aside (2026-10-08)

Only the Full Disk Access paragraph changes between never / stale / revoked; title, buttons and the show rule do not. Stale carries the remove-and-add step as a numbered two-step list (text-primary, body size), followed by the quit-and-reopen line. Revoked stays one paragraph. The signing paragraph now shows in every notice (release and self-built are both ad-hoc signed) as secondary text: last in the body, caption size, text-muted. Shown scrolled to the end, where the branch and the aside sit.

Layout spec
branches
never · stale · revoked — FDA paragraph only
stale steps
ordered list 1–2 · body 13 · text-primary · indent space-xl · gap space-xs
signing aside
every notice · last · caption 12 · text-muted · command chip unchanged
new strings
stale step 1 · stale step 2 · stale retry line (split from the old stale paragraph)
Tokens used
text-muted— signing asidefont-size-caption— signing asidespace-xl— step list indent

Script changed confirm (2026-10-09)

Wrapped body · height fits the content

The confirm stays 360 wide in every language. The changed Tag sits on its own line and the explanation is a full-width paragraph under it, so the text never starts beside the tag and wraps back under it (no horizontal_wrapped). Height fits the content — no 152 floor, so ko leaves no empty band under the buttons and ja grows by one line. Body → buttons is --tasty-space-md (8 row gap + 4 extra). Buttons stay inside the 360 (right-aligned, Run anyway primary, Cancel ghost).

Layout spec
width
360 × UI scale · all languages
rows
title (body 13 semibold) · path (mono caption, ellipsis) · Tag warning “changed” · paragraph (caption 11, text-secondary, line-height-ui, full width) · buttons
row gap
space-sm 8 · Tag → paragraph space-xs 4
body → buttons
space-md 12
height
fits content (title bar + padding + rows) · no minimum
padding
12 top/bottom · 14 sides
buttons
right-aligned · Cancel ghost sm · Run anyway primary sm · inside 360
Tokens used
accent-warning— changed Tagtext-secondary— paragraphspace-md— body → buttonsshadow-modal— popup

Explorer — Delete permanently (2026-10-12)

Destructive confirm with a target list

Window-scope centred confirm on the shared scrim, opened by the Explorer's Delete permanently…. Reuses the info-modal frame size (440, max 360) because it carries a scrolling target list. Default focus Cancel; Esc, close and scrim click cancel. The full state set (file, folder, link, many, focus, ko/ja, progress, results) is on Explorer — file operations › Delete permanently.

Layout spec
width
info-modal-width 440
max height
info-modal-max-height 360 · list scrolls past explorer-delete-list-max-height 160
buttons
Cancel ghost (default focus) · Delete permanently danger
dismiss
Esc · close · scrim click = cancel
Tokens used
accent-danger— glyph + buttonexplorer-delete-list-max-height— list