Files
tinycast/docs/ui.md
T
notsoshu 850a519d42 Rebuild the AI Providers panel: a list with pages, a say over models, and per-tool launch settings (#1206)
* Show AI providers as a list beside the selected provider's detail

The Providers panel was one long form: every installed tool and API
connection stacked as rows, with status, quota and actions squeezed into
each. It now follows Mail's Accounts: the on-device model, the installed
commands and the API connections sit in a list, each with a one-line
state and, for installed tools, its switch. The selected provider shows
its status, account, usage, command path and models beside it, with a
filter once a list runs long. API connections are added from the list's
plus menu, removed with its minus, and still edited in the existing
connection editor.

* Choose which of a provider's models the model picker lists

A route like OpenCode offers hundreds of models, and every one of them
landed in the chat's model picker. Each provider's model list is now a
set of checkboxes, with Show All and Hide All above it. A provider
nobody has trimmed keeps listing everything, including models it adds
later; once trimmed, it lists only what was ticked. The default model
always stays listed, since the picker has to show what it holds, and a
removed connection forgets its choices. The choice is per Mac and stays
out of settings backups.

The provider switch moves from the list into the selected provider's
header, so it no longer sits on the list's blue selection, and model rows
drop their effort levels, which the chat's own picker already shows.

* Give Grok, Cursor and OpenCode their own marks

Grok borrowed X's mark, Cursor a pointer symbol and OpenCode a terminal
symbol. Each now has its own, in the Providers list and in the chat's
model picker. OpenCode's inner block is drawn with `opacity`: the asset
compiler drops `fill-opacity` without a warning, which filled the block
solid and turned the mark into a window with a title bar.

* Name ChatGPT's newer plans

Pro now comes in two sizes, which the account reports as "pro" and
"prolite" and ChatGPT sells as Pro 20x and Pro 5x. Business, Enterprise
and Edu each gained plan types that read as "Account".

* List a provider's models in a table

A Form realizes every row it holds, and OpenCode offers over four hundred
models, so its list stuttered while scrolling. The checkboxes now sit in
an NSTableView inside one Form row, which builds only the rows on screen.

* Switch the on-device model or an API connection off in place

Only an installed tool could be switched off; an API connection had to be
removed, key and all, to leave the model picker. The on-device model and
every API connection now have the same switch. A route that is off stays
configured but leaves every picker, the default model moves to a route
still on, and asking for it anyway is refused with a message. Removing a
connection forgets that it was off, and the choice stays out of backups.

* Launch an installed tool from a set path, with set variables

Tinycast finds each installed tool by asking a login shell, which is
right until a Mac has two copies or a shell it cannot read. A tool can
now carry a command path that replaces the lookup, and variables set for
that tool each time it starts. Nothing sets them yet; the next commit
adds the fields.

A path with nothing to run fails the check and the turn, never falling
back to the lookup, since that would hide the mistake the path was set to
fix. The reader's variables lie over the app's own, but never over one
Tinycast sets to keep a tool inside the chat. Names are stored in
settings and values in the login Keychain; neither travels in a backup,
which must not decide what this Mac launches. Only the tool that was
edited is checked again.

* Add an Advanced section to an installed tool's detail

The command path has a field and a Choose… button whose open panel shows
hidden folders, since `~/.local/bin` is one. Variables are rows of a name
and a hidden value, added and removed one at a time. A name Tinycast sets
itself says its value is not used, and one that is not a variable name
says why. Each field saves as it is left.

* Name a Claude model by its display name when the description has no version

Claude Code used to lead a model's description with its version, "Opus 5.5
· Best for everyday…", and Tinycast named the model from that. A newer
CLI puts the version in the display name and leaves only the blurb in the
description, so every model in the picker read "Claude Best for everyday,
complex tasks". The version is now taken from the description only when
it is there, and from the display name otherwise.

* End an edit in the Providers panel with a click outside the field

A field holds the keyboard until something else takes it, and blank form
space takes nothing. The panel now releases it the way every other pane
does, which is also what saves a field that is saved as it is left.

* Show Claude's account and Codex's command, and keep Advanced while a tool is off

Claude's detail now names the account it is signed in with and its plan,
the address hidden until clicked as Codex's is. Both come from the answer
that already lists Claude's models, so nothing more is asked of the CLI.
Codex's detail names the command it ran, which the other tools already
did, and the empty command path field shows it too.

A tool that is switched off keeps its Advanced section, so a wrong path
can be fixed before it is switched on.

* Give a provider's detail its own pages

A provider's status, its models and its advanced settings shared one
scrolling form, so OpenCode's four hundred models buried everything under
them. The detail now has Overview, Models and Advanced pages behind a
segmented control, as Mail's Accounts has. An API connection has the first
two, and the on-device model, with one page, shows no control. The chosen
page is kept from one provider to the next.

* Keep the accent colour on the providers panel's page control

An editor panel wraps its content in Liquid Glass, and a segmented control
drawn on glass goes grey and bordered. The divider beside the selected
page is always hidden, so in grey the control read as missing one. The
providers panel now draws its glass behind the content, which leaves the
control its accent colour; every other editor panel is unchanged.

* Keep the MCP editor's connection control from resizing

The system segmented control opens tight around its labels and widens the
first time its selection changes, so the HTTP and Command control grew
under the pointer and stayed that wide. The editor now uses the AppKit
control with each segment pinned to its label plus the inset the control
settles at, and reports its own size from those widths, so it opens at the
size it would have ended up.

* Say Automatic in an empty command path field

The empty field showed the path the lookup had found, which read as a
path already set, and repeated the Command row on Overview. It now says
Automatic; the found path is on Overview alone.

* Credit the three new marks, and take two from the sources already credited

Cursor's mark now comes from Simple Icons and Grok's from Lobe Icons, the
two sources NOTICE already names; both look as they did. OpenCode's is
drawn after the one in its own repository, since the libraries carry the
frame without the block inside it, and its licence travels with it.

* Document the Providers panel, its pages and a tool's overrides

The feature doc gains the rules that are now invariants: a route that is
off is off everywhere, a picker lists what was ticked, a set command path
wins or fails, and a reader's variable never replaces one Tinycast sets.
It describes the panel's list and pages, and says why three more AI keys
stay out of backups and settings.json. The UI doc records the panel's
sizes and the two things learned about controls in an editor panel. The
website explains the pages, the switch, and the Advanced page.

* Keep a tool's variables when the Keychain cannot be read

A failed read loaded the variables' names with blank values, and leaving
any field in Advanced then saved the blanks over the stored values. The
read now throws, the page says it could not read them and saves only the
command path, and a save whose check read fails is refused.
2026-09-29 00:57:58 +06:00

76 KiB
Raw Blame History

UI & Design System

The design system for Tinycast's UI, written so an agent restyling or extending it stays consistent with what's already there. This documents Tinycast as built — every rule here maps to code in Tinycast/. DesignSystem/Theme.swift is the single design-token source.

Read this before touching any view body, Theme value, or the panel chrome.


The look, in one paragraph

Tinycast is a command palette: a borderless floating panel whose surface is just the OS behind-window blur under a 40% black scrim — there is no gray chrome. Everything on that surface is white at a fixed alpha ramp. The header and bottom bar float over the list as fully transparent overlays; there are no hard-edged bars, strips, or dividers. Rows don't clip under the bars, they dissolve: a scroll-driven gradient mask ghosts them as they pass beneath. Floating controls (the action pill, the menu circle, popover menus) are Liquid Glass.

That paragraph describes Dark, which is the design. Light is the same design with the ink inverted: a white scrim over the same blur, and a black-alpha ramp at matched stops. Nothing about geometry, type, motion or state changes between them.

Five load-bearing ideas, in priority order:

  1. Surface = scrim over behind-window blur. No solid backgrounds. Depth comes from the desktop showing through.
  2. One alpha ramp, never grays. Ink at fixed stops — white over the dark surface, black over the light one.
  3. Floating bars, not chrome. Header/footer are transparent overlays; the list fills the whole panel.
  4. Edges dissolve, they don't clip. Scroll-driven mask, no separators between list and bars.
  5. Glass only on floating controls. The main surface is never glass; pills/menus/circles are.

Non-negotiable invariants

These are the things that quietly break the look if changed. Preserve them unless the task is explicitly to change them.

  • Dark is the baseline and its values are frozen. Every Theme.Colors token resolves per appearance, and its dark branch is the literal the forced-dark build shipped — restated, never recomputed. Retune a light branch freely; touch a dark one only when the task is to change Dark. AppCore.applyAppearance() is the only place an appearance is assigned, from AppSettings.appearance; .system assigns nil so AppKit follows macOS.
  • New colors go through Theme.Colors.ramp(dark:light:) (an alpha that inverts) or adaptive(dark:light:) (two explicit NSColors, for anything that isn't a plain inversion — panelScrim, layoutPreviewGround). Never a bare Color.white.opacity(…) in a view: it disappears in Light.
  • No grays, no opaque fills on the surface. Reach for Theme.Colors.* instead of .gray, NSColor.windowBackground, etc.
  • Three things stay fixed in both appearances, on purpose. The EdgeDissolve/OverflowFade gradients are mask luminance, not color — inverting them breaks the dissolve everywhere. ExtensionTintColors and a tinted IconCache tile keep white ink, because a saturated tile carries its own contrast. And IconCache cannot use a dynamic NSColor at all: it rasterizes off-main, so the surface is carried explicitly and is part of the cache key.
  • An icon is drawn for a surface and a system icon style, and both move under you. macOS restyles the icons NSWorkspace hands out when System Settings → Appearance → Icon & widget style changes, so IconStyleMonitor and Tinycast's own appearance both call IconCache.invalidateStyled(). The monitor may not invalidate on the notification itself. AppKit posts NSWorkspaceIconAppearanceConfigurationDidChange before IconServices has swapped what NSWorkspace vends — measured at 25–120ms behind, jittering run to run — and the images it hands back are live objects macOS restyles in place, so flattening one on the signal freezes the outgoing style into a bitmap nothing ever invalidates again. IconStyleMonitor therefore polls IconCache.styleFingerprint() until the pixels actually move, and only then invalidates. Waiting also sidesteps the cost: re-flattening every icon the instant a restyle begins forces a cold IconServices regeneration, measured at 160× the settled draw cost. That drops the cached bitmaps, bumps every cache key so an in-flight decode cannot repopulate a stale one, and moves IconCache.style.generation. Any view that draws an icon must key its fetch on that generation — wrap the view's own key in IconRequest, or call IconCache.observeStyle() where the icon is resolved synchronously in a body. It is reached through IconCache rather than injected precisely because icons are drawn in menus, popovers and every list, where a missed injection would be a silent staleness bug.
  • No hard dividers between the list and the bars. The header and bottom bar are safeAreaInset overlays with no background; separation comes from edgeDissolve(), nothing else. (One deliberate exception: the vertical hairline between a list and its preview pane, as the clipboard and file search screens draw.)
  • The panel corner is clipped once, at the root. RootPaletteView.body ends with .background(panelScrim) → .background(GlassEffectView()) → .clipShape(RoundedRectangle(26, .continuous)). Keep that order; the scrim goes over the glass, and the clip is last.
  • Don't use the native scroll edge effect. Inside a transparent panel it renders a hard-bounded rectangle. Use edgeDissolve(), or a gradient mask where a surface owns its own fade — scrollEdgeEffectStyle draws a material where a scroll view meets a safe area, so over a panel that already has panelScrim + GlassEffectView it composites to nothing. Tried and rejected on QuickActionResultView, with and without safeAreaBar. This is a rule about the borderless panels; the Settings window is a titled NSWindow whose system titlebar draws the band itself (see "Settings").
  • Test over a light desktop. Transparency and corner masking bugs only show over bright wallpaper. Dark wallpaper hides them.
  • No NSAlert or system popovers. Every confirmation, failure report, value prompt and transient readout is Tinycast's own SwiftUI surface (see "Dialogs & HUD"). An Aqua alert on an alpha-over-vibrancy app reads as a different product, and its runModal run loop keeps Carbon hotkeys firing underneath.
  • A dialog has three independent axes; never let one infer another. The icon (DialogRequest.symbol, required) is always the subject's own glyph — a command being confirmed uses its SystemAction.sfSymbol, so the Restart dialog shows the same icon as the Restart row. Tone never picks an icon. The tone (DialogTone: .neutral / .success / .danger) tints only that glyph. The button takes its color from DialogAction.Role (.standard white / .destructive red / .cancel secondary), so a red-glyph security warning can still carry a plain white button — as "Import executable commands?" does.
  • Resolve every glyph through SymbolImage, not Image(systemName:). Some catalog symbols are bundled assets in Assets.xcassets (toggleBluetooth), and Image(systemName:) silently renders nothing for those.
  • ↵ runs the primary action, Escape cancels, and Cancel always renders leading (the left button), matching macOS convention. A button never prints its key cap; a deliberate hover reveals its outlined KeyCapChip in a Tooltip.
  • In the palette, a hover label is Tinycast's tooltip, never .help(): an AppKit tooltip never appears while the app sits inactive behind the non-activating panel. A Settings window activates the app, so .help() shows there and stays the label to use. The tooltip hangs above its control by default; a control in the palette header passes edge: .bottom, since above it is off the window, and a label may run to several lines — the chat's attachment pill lists every staged name.
  • A transient readout is a HUD, not a dialog. VolumeHUDController's box is volume and mute only, since that one needs an actual level and number; every other success or info confirmation goes through MessageHUDController's pill, whose trailing glyph is its DialogTone. A pill has no subject to name, so the icon rule above does not apply to it — and that mapping stays file-scoped so nothing can reach for it when building a DialogRequest. A new HUD means a new presenter, not a second shape bolted onto an existing controller.
  • Glass is for floating controls, with dialogs as the deliberate modal exception. The action capsule, menu circle and PopoverMenu use it inside the palette; dialogs apply one system .glassEffect(.regular) to their root surface. Dialog buttons stay matte so their roles remain legible. Both HUDs keep the lighter panelScrim → GlassEffectView() → clipShape recipe.

Tokens

Source: Tinycast/DesignSystem/Theme.swift.

Theme is the single source of truth. Never hardcode a spacing/radius/size/color that has a token. Add a token rather than a magic number when introducing a new value.

Interface Size (InterfaceMetrics)

AppSettings.interfaceSize scales the palette and the surfaces that float with it — the ⌘K menu, the extension list panel, Quick Actions, dialogs and HUDs. Settings, Onboarding, Support, Update, About and Notes never scale.

DesignSystem/InterfaceMetrics.swift stores only a scale and derives every value from the Theme literal, so Theme stays the one place a number is written down. In any view a scaled surface can reach, read @Environment(\.metrics) rather than Theme.Spacing/Radius/Size/Typography — the key defaults to .standard, so a shared DesignSystem/ component renders unscaled in Settings without being forked. An AppKit site reads settings.interfaceSize.metrics where it computes its frame.

A length measured against the screen does not scale; a length measured against our own content does. So hairline, paletteTopMarginFraction, paletteSnapDistance, paletteMinimumVisible, the drop-guide dashes, hudEdgeOffset and every row count stay on Theme, as does every chrome token.

Scaling rounds to whole points, once, at the leaf accessor. A derived token composes already scaled parts (compactHeight, menuRowHeight) rather than scaling the derived result, so an AppKit frame can never disagree with the SwiftUI view inside it by a point. interface-size-test pins all of this, member by member, including that .standard is Theme verbatim.

Spacing (Theme.Spacing)

xxs 2 · xs 4 · sm 6 · md 8 · lg 10 · xl 12 · dialogInset 18 · xxl 20

xxs is the tight gap between adjacent keycap chips (used everywhere keycaps sit side by side).

Row content insets are md; list horizontal inset is md; the search icon aligns with rows via md * 2.

Section-header rhythm has two dedicated tokens: sectionHeaderBottom (header → first row) and sectionSpacing (gap above every header except the list's first, which reads as the previous section's closing padding). The emoji grid uses the roomier emojiSectionSpacing between its tile groups. See "Section headers" below.

Radius (Theme.Radius)

panel 26 · row 10 · card 10 · dialog 20 · dialogSymbol 16 · menuPanel 16 · menu 6 · menuRow 10 · barControl 8 · thumbnail 6 · keyCap 6 · recorderKeyCap 4

barControl dresses the header pop-ups (type filter, AI model), which state a value and drop a menu the way a native pop-up button does — a rectangle, not a pill.

The footer action group stays a Capsule, and so do the buttons inside it. It is the primary action, and the pill is the affordance saying so; squaring it off reads as a toolbar. The capsule also keeps the pair concentric for free — a capsule's radius is half its height, so the inner buttons land exactly Spacing.xs inside the wrapper without either radius being written down.

Notes has no corner of its own: it clips to panel, so the two floating surfaces read as siblings.

dialog sits between menuPanel and panel so a dialog reads as a smaller sibling of the palette, not a second palette.

menu is the shared small-control corner (sidebar tiles, About link pills); menuRow is the slightly rounder hover highlight behind popover-menu rows.

Always RoundedRectangle(cornerRadius:, style: .continuous) — continuous corners everywhere, never .circular.

Concentric corners

Where two rounded corners sit adjacent and are seen together, the inner radius is the outer radius minus the gap between them. Miss it and the curves stop being parallel: the inner corner reads tight or slack against the one behind it, which is visible long before anyone can name it.

Inside a menu the rule holds exactly, and menuRow exists to keep it holding:

Outer Gap Inner
menuPanel 16 Spacing.sm 6 menuRow 10

Alignment to a control outranks it. Every palette menu hangs off a button, and its edge lines up with that button's — MenuPanel.inset is Spacing.md, which is bottomBar's own horizontal padding, and the header menus use Spacing.md * 2 to meet the header button in its wider gutter. The buttons sit directly under the menus, so a 2pt disagreement there reads as broken padding, while the same 2pt against the panel's corner reads as almost nothing. Anchor to the control, then let the corner fall where it does: panel 26 against an 8pt gap would want menuPanel 18, and it stays 16.

It applies only where corners actually meet. A list row sits mid-panel with the header above and the bottom bar below, so it shares no corner with panel and has nothing to be concentric with — row 10 is chosen for the row's own size and stays on the shared scale. Forcing the rule there would round every row to 18 for no reason.

If a pair ever does need closing, move the gap, not the curve — but only once you have checked what else is anchored to that gap. A radius is shared by surfaces across several features, a placement constant is not: Radius.menuPanel alone dresses the ⌘K menu, the extensions actions panel, the shortcut-recorder callout and the Notes switcher, and menuRow is deliberately equal to row so a row pill is one shape everywhere.

Size (Theme.Size)

panelWidth 750 · panelHeight 475 · headerHeight 44 · bottomBarHeight 52 · barButtonHeight 28 · rowIcon 24 · keyCap 18 · recorderKeyCap 16 · menuButton 36 · clipboardListWidth 290 · menuWidth 276 · clipboardFilterMenuWidth 200 · fileSearchFilterMenuWidth 200 · emojiCategoryMenuWidth 220 · menuIcon 20 · emojiGridInset 16 · settingsSidebar 215 · settingsRowIcon 20 · dialogCompactWidth 290 · dialogWidth 420 · dialogButtonHeight 34 · dialogSymbol 28 · dialogSymbolContainer 52 · dialogIcon 32 · hudWidth 200 · hudHeight 100 · volumeTrackHeight 6 · volumeReadout 38

Notes adds noteWindow 520×420 (opening size on a first run only), noteWindowMinimum 320×220, noteTitlebar 44, noteTitleInset 120, noteEditorInset 16, noteSearchHeight 34, noteFooterHeight 28, noteGlyph 16, noteEmptyGlyph 28, and noteHeadingMenu 220×159.

AI Chat adds aiChatWindow 960×660 (opening size), aiChatWindowMinimum 680×440, a sidebar of aiChatSidebarMinimum 240–aiChatSidebarMaximum 340, aiChatDetailMinimum 440, aiChatReadingWidth 760 for the transcript and composer column, aiChatComposerMaxHeight 180, and chatContextGauge 14 for the composer's context ring, and chatContextCard 300 for the card it raises on hover.

The AI Providers panel adds aiProvidersPanel 840×520 (the height is its two columns', stated so that selecting a longer provider never resizes the panel), aiProvidersList 262, aiUsageBar 110 and aiVariableName 170. segmentLabelInset 13 is what the system leaves either side of a segment's label once a segmented control has settled.

keyCap sizes the palette's keycap chips; recorderKeyCap (both size and radius) is the intentionally-smaller Settings shortcut-recorder chip.

Typography (Theme.Typography)

System text styles only — no fixed point sizes in views. Two named exceptions are explicit: searchField (20pt Regular) and the optical SF Symbol treatment menuSymbol (14pt Medium). Use rowTitle (.body), sectionHeader (.subheadline.medium), rowTrailing/bar/menuRow/keyCap etc. as named.

InterfaceMetrics.Typography scales a style by rebuilding its NSFont from that font's own descriptor at the scaled point size. Never reconstruct one as .system(size:weight:) from a hand-written weight table: on macOS .headline is Bold and .caption2 is Medium, so a table lightens them the moment the user leaves the default size. menuSymbol is the deliberate exception: it is an explicitly-sized glyph treatment, not a system text style, so scaling preserves Medium.

Colors (Theme.Colors) — the alpha ramp

The Dark column is the design and is frozen; each value is the literal the forced-dark build shipped. Light is the same stop with the ink inverted, and is the only column open to retuning.

Token Dark Light Use
panelScrim black 0.40 white 0.55 the panel scrim over the glass
selection white 0.10 black 0.09 selected row fill (keyboard/active selection)
rowHover white 0.05 black 0.045 mouse-hover fill (always fainter than selection)
menuHover white 0.10 black 0.09 popover-menu row hover
separator white 0.10 black 0.12 a list↔preview hairline (clipboard, file search)
controlSurface white 0.10 black 0.08 filled keycaps, glyph tiles
border white 0.20 black 0.18 outlined keycap borders
textPrimary white 1.00 black 1.00 search text and caret, volume fill and knob
textSecondary white 0.60 black 0.60 secondary labels
textTertiary white 0.40 black 0.42 placeholders, trailing kind labels
menuSymbol white 0.70 black 0.70 native popover-menu symbols
iconPlaceholder white 0.06 black 0.06 the empty tile a row paints while an icon decodes
sheen white 0.04 black 0.04 the wash behind the Onboarding header
cardFill white 0.05 black 0.04 settings/calc card fill
cardStroke white 0.10 black 0.10 settings/calc card border + inset dividers
noteText white 0.90 black 0.85 Notes body text
dropGuide white 0.35 black 0.35 the palette's drop guides while dragging

panelScrim is the ramp's inverse — it darkens the dark surface and lightens the light one — so it is an adaptive pair, not a ramp. brand, primaryAction, destructive, success and dropGuideArmed are fixed hues and adapt on their own.

Beyond these, .secondary/.tertiary foreground styles are fine for SF Symbols (they resolve against the environment's appearance). Selection always beats hover when a row is both.

An extension's own surfaces live in ExtensionColors (Features/Extensions/UI/), not here — the ramp mechanism is shared, the values are the feature's. See the Extensions non-negotiable in AGENTS.md.


Panel structure

Source: Palette/PalettePanel.swift, Palette/RootPaletteView.swift.

  • PalettePanel is a borderless NSPanel: isOpaque = false, backgroundColor = .clear, .palette level (one above .modalPanel, so other apps' open panels never cover it), hasShadow, animationBehavior = .none. It hosts SwiftUI via NSHostingView. PaletteWindowController centers it slightly above screen center (+8%) and dismisses it on windowDidResignKey.
  • The results layer fills the whole panel. The header and bottom bar attach via .safeAreaInset(edge: .top/.bottom) as transparent overlays that float over the list. The list underlaps them and dissolves at the edges.
  • Header (headerHeight 44): a back-chevron or mode glyph, then the plain TextField (no border/background). Sub-screens (Clipboard, Calculator History) show the back chevron; the launcher shows a magnifying glass. The search icon aligns horizontally with row content.
  • Compact keyboard entry: pressing ↓ in the collapsed launcher expands the results and selects the first row without replacing or defocusing the shared search field.
  • Bottom bar (bottomBarHeight 52): a menu circle on the left, the action group on the right — both floating glass, no bar background. The action group is one glass Capsule holding the primary-action pill (label + ↵) and the Actions toggle (⌘K).
  • BarButton is the shared bar control: bare label at rest, a rowHover capsule on hover, barButtonHeight 28. Set isSelected and it fills with selection instead, which beats hover; the Notes formatting bar lights its buttons this way. Set isCompact for sm padding instead of md: around a 16-point glyph frame that makes a 28-point square. It carries the footer's two buttons and the clipboard header's type filter, so those hover identically. Hover state lives inside it, so sweeping one never re-renders the palette body.

Notes panel

Source: Features/Notes/UI/.

Notes is a sibling surface, not a palette mode. NotesPanel is a titled, resizable, non-activating panel — AppKit draws the traffic lights, the drag and the resize — but it keeps the palette's transparent recipe and deliberately does not dismiss on resign-key. NotesView's root applies panelScrim → GlassEffectView() → one continuous panel corner clip, so Notes and the palette round identically. The clip is larger than the theme frame's own corner, so it is what shows; invalidateShadow() on every show recuts the shadow to match.

The title bar is a 44-point band, and both halves of it are deliberate. titleVisibility is .hidden and NotesView draws the title itself, centred on the window: a titlebar accessory drops NSThemeFrame off its centred-title layout, so the native title would sit beside the traffic lights. The drawn title is not hit-testable, so clicks fall through to the real title bar and drag the window. The three actions cannot do that, so they live in an NSTitlebarAccessoryViewController at .trailing — NoteTitlebarActions, the launcher's footer capsule (BarButton in a frosted(in: Capsule())) with glyphs in place of pills. Its 44-point height is what sizes the band.

NotesWindowController preserves the user-owned size and AppKit autosaves the frame under "Notes Window"; only a title-bar double-click computes a top-right target. The window shows exactly one surface at a time — editor, switcher, or the "No Notes" empty state — and the character count is part of the editor surface, so it never appears without a note.

The header keeps a fixed slot for status so Saving, Saved, failure, and conflict symbols cannot move the controls. Failure and conflict symbols can be clicked to reopen their recovery report after a dismissal. A title click opens the in-window note switcher; dragging the title, note icon, or otherwise empty header moves the panel after a three-point threshold. Create, Reveal, Hide, and actionable status remain click-only controls. Escape closes the switcher before hiding, while Command-W and the hide control order the panel out. Show Notes only shows or focuses; focus loss leaves the panel visible.

The editor is one native TextKit 2 surface. Its string is the canonical Markdown source, and Render Markdown styles it in place with no new tokens. Notes type sits one system text style above the rest of the app, because a note is for reading: body text is the title3 size in noteText, and headings 1 to 3 use the largeTitle, title1 and title2 sizes (bold, bold, semibold). Interface Size does not scale it. Inline code is monospaced on controlSurface, and links use the system link colour. Quotes and checked tasks dim to textSecondary, and a checked task is struck through. Most markers show in textTertiary on the caret's line and are hidden elsewhere. Bullets keep their rendered dot even under the caret; revealed list markers stay textSecondary. Revealed non-bullet list and quote markers hang left of their text, so the text does not move when the caret arrives, unless the marker is wider than the slot. Empty list items keep body-sized invisible markers so their rows match filled items' height.

A layout fragment draws the block chrome. A code band fills cardFill with menu corners at its ends and a textTertiary language label, inset by lg. A quote bar is markdownQuoteBar wide in border, stepping markdownQuoteBar + lg per depth. A rule is a hairline of separator. List markers sit in a slot per level, markdownListMarker grown in proportion to the body size. Bullets, numbers and checkboxes are textSecondary; a done box is filled with the check cut out. Headings 1 and 2 get xl space above, the rest md, and xs below; every list item gets md below. A table stays literal source in the code-block font, and a wrapped row hangs lg under its first line. AppKit owns editing, undo, selection, Find, and marked text.

Under the editor, the formatting bar takes a bottomBarHeight band while Render Markdown and Show Formatting Bar are on, in place of the 28-point count footer. It is one row, not a capsule hugging the window: the count is plain text at the leading edge and the buttons sit in glass at the trailing edge, the same split as the palette's own bottom bar. The capsule is the title bar's recipe (BarButtons in frosted(in: Capsule())), inset md from the trailing edge as the title capsule is, with xxs between buttons and sm between groups. Each button is a compact 28-point square around a noteGlyph frame, so the capsule is 36 points wide collapsed and 397 expanded, and the count gives way from 524 points down. Collapsing and expanding grows the buttons out of the round button on MenuMotion.chevronAnimation, wrapped in an explicit withAnimation in the coordinator because the chord changes that state outside any view's transaction, and skips the animation under Reduce Motion. The band takes only the width it is offered, so the bar can never widen the note; past that, only the capsule's leading end clips. Lit buttons use BarButton.isSelected; hovering shows a Tooltip with the name and shortcut, because an AppKit tooltip does not appear while the app is inactive behind this non-activating panel. The glass is a background, never a wrapper, and nothing clips the row, because either one swallows that tooltip. The round button aligns its tooltip trailing and the heading button leading, so neither runs past the window edge. The heading menu is a borderless child window like the switcher, noteHeadingMenu in size, hung off the heading button's own reported frame and xs above it, drawn with PopoverMenuRow's metrics on menuPanel glass.

The switcher is its own glass panel over the editor, sized to its list up to a 240-point ceiling and never resizing the note window. Its plain search field and keyboard-navigable rows use the shared selection/hover ramp; rename and Trash remain row actions rather than adding another toolbar or window.

The switcher exposes activation, Rename, and Move to Trash as VoiceOver actions with the actual note title. Its hover buttons are hidden from accessibility so those actions are announced once. See features/notes.md.


AI Chat window

Source: Features/AI/UI/AIChatSplitViewController.swift, AIChatDetailView.swift.

AI Chat is a titled window built the way Settings is, not a palette sibling like Notes: a real NSSplitViewController whose sidebar item takes the system sidebar material, a unified toolbar with an inline title, and native List, Menu and context menus. Nothing about it scales with Interface Size. The composer is untinted Liquid Glass at Radius.dialog, stacked under the transcript so nothing scrolls behind it, with .glass capsules for its model and reasoning menus, and its glass on a background layer rather than the box. The title bar keeps the system's own toolbar band, as any document window's does. The context card the gauge raises on hover is glass over a solid windowBackgroundColor, because it rises over transcript text, and sits in the transcript's own frame at its bottom edge, so no window size can push it off screen. A reply's choices are .glass capsules that rise out of the composer's top edge. Find marks words in Colors.findMatch / findCurrent, the system yellow, with findCurrentInk black on the solid one in both appearances. Transcript lines sit spacing.chatLine 4 apart on both surfaces. The transcript itself is the palette's ChatTranscriptView with surface: .window, which leaves out edgeDissolve and thinScrollbar — both are measured against the palette's bars — and caps the column at aiChatReadingWidth, centred.


The edge dissolve

Source: DesignSystem/Scrolling/EdgeDissolve.swift.

The signature effect. A scroll-driven LinearGradient mask on each list so rows soften as they approach a floating bar, ghost beneath it, and vanish only at the window edge. Attach with .edgeDissolve() on the ScrollView, before .thinScrollbar() (so the scrollbar overlay stays unmasked).

  • Fade bands: top = headerHeight + headerPadding + 32, bottom = bottomBarHeight + 28 — each overshoots its bar into the visible list, so the ramp finishes ~32/28px past the bar rather than cliffing at its edge.
  • Alpha floors mid-scroll (not to 0): top 0.15, bottom 0.25, eased by how much content is hidden past the edge (1 − (1 − floor)·clamp(dist/band, 0, 1)).
  • Only masks when the list is scrollable; the edge stop stays transparent so rubber-band bounces still dissolve. A list that fits gets no mask.
  • The mask spans the scroll view's full frame (.ignoresSafeArea()) — otherwise the bars' safe-area insets shift the gradient onto at-rest rows.

Palette only. Every one of its call sites is a palette screen, and the bands above are measured against the palette's bars. A Settings list underlaps nothing, so it uses .overflowFade() instead — and so does the Notes switcher, whose search row is a sibling in a VStack, not a floating bar.


The overflow fade

Source: DesignSystem/Scrolling/OverflowFade.swift.

The counterpart for any list with no bars over it — the Settings lists and the Notes switcher — and deliberately a separate type: sharing one modifier would tie such a list to geometry that only means something under a bar. Attach with .overflowFade() on the ScrollView, before .thinScrollbar(), or pass includingTop: true for a bounded popup whose title and rows scroll together.

  • Bottom by default. Settings and Notes have nothing above their lists; popups opt into the top edge because their content, including the title, can scroll past it.
  • Fade band: 24px with the original single-stop curve by default; popups opt into the progressive top-and-bottom curve and their own 30pt band.
  • No alpha floor. Each enabled edge eases with how much content is hidden there and clears completely once the list rests against it.
  • No .ignoresSafeArea(): a Settings list carries no bar insets to correct for.

Rows, selection, hover

Source: Launcher/UI/LauncherList.swift, Clipboard/UI/ClipboardView.swift, FileSearch/UI/FileSearchList.swift, Uninstall/UI/UninstallView.swift.

All lists share one row grammar so launcher and clipboard look identical:

  • HStack(spacing: lg): leading 24pt icon/thumbnail, title (.body, lineLimit(1)), optional trailing keycaps/kind label, Spacer. Insets: .horizontal md, .vertical sm.
  • The leading slot is always Theme.Size.rowIcon, whatever fills it. A glyph smaller than an app icon — the uninstall list's 16pt checkbox — is centred inside that 24pt slot rather than sizing the slot to itself. Every list then starts its title at the same x, so switching palette modes doesn't jog the column sideways. The slot doubles as the hit target.
  • Background is a RoundedRectangle(row, .continuous) filled by fill: selection → hover → clear, in that precedence. This fill computed property is copy-identical across AppRow, ClipboardRow and UninstallRow — keep them in sync. The launcher's lead cards don't restate it: .leadCard(selected:) (Features/Launcher/UI/LeadCard.swift) owns their fill and hover, so a card can't answer a selection differently from its siblings.
  • Hover state lives on the row, not the list, so a mouse sweep repaints only the rows entering/leaving (a list-level hover rebuilds every row per move — don't do that).
  • Hover is armed by pointer movement, not by the pointer's position (armedHover, Palette/HoverArming.swift). A palette shown under a resting pointer lights nothing, and keys or a scroll drop the highlight until the pointer moves clear of the slop radius around where it stood — a row must never light up because it slid under a still pointer. Two measured facts the rule rests on: SwiftUI fires hover phases for rows arriving under a stationary pointer, but not for a lit row that merely shifts, so PaletteState.hoverDisarmToken clears what is already lit; and a wheel gesture ends with a mouse-moved event carrying no displacement, so event type is not evidence the pointer moved. Tests/hover-arming-test.swift pins both halves.
  • Scroll moves only on keyboard nav/reset, driven by a ScrollIntent (DesignSystem/Scrolling/ScrollIntent.swift) — mouse selection targets a visible row and never yanks scroll. .top scrolls to the origin anchor that scrollOriginAnchor() installs — a zero-height overlay applied to the scrolled content after its padding, so it marks offset 0 without joining the layout and the restored origin is exact (targeting the first row instead leaves the top padding hidden under the header); it is restated when the header's inset settles after mount, which moves the resting offset. A .follow that lands on flat index 0 restores the origin instead, so that row's section header comes back into view. A reset lands on the screen's landingSelection through RootPaletteView.land(): row 0 takes .top; a later row takes .center, which brings a row the lazy stack has not built in by id, centres it once its frame is measured and then lets go — restated, like .top, when the inset settles. One intent state serves every mode — they never coexist.
  • .follow is an invariant, not a command (scrollFollowsSelection, DesignSystem/Scrolling/). Each list marks its selected row with selectionFrame(_:), and the modifier keeps that row inside the band between the floating bars, re-checking as the geometry and the row's frame settle, then stops watching the moment the row is inside. That self-release is what keeps it safe: once a keystroke has landed nothing is observing, so a wheel scroll — or a scrollbar-thumb drag, which onScrollPhaseChange cannot see at all — is never pulled back. Two measured facts it rests on: frame(in: .scrollView) reports the inset-excluded space, so the band is simply 0…containerSize.height; and SwiftUI's minimal scroll-to-visible counts the strip behind the bottom bar as visible while its destination math respects the insets. Hence the split — Tinycast decides whether to scroll (SelectionReveal, pure, pinned by Tests/scroll-reveal-test.swift) and SwiftUI performs the move with an explicit .top/.bottom anchor. Scroll far by hand and the lazy stack drops the selected row, so there is no frame to measure at all: the fallback brings it back by id and the invariant, still standing, re-checks the moment it reports — which is why arrowing after a long mouse scroll lands the selection on screen rather than moving it out of sight. A one-shot scrollTo here left the highlight stranded under the pill whenever the target row's layout was not yet known, with nothing looking again until the next key press. The id passed to scrollFollowsSelection must be the lazy container's own ForEach identity — an .id() applied inside a row registers only once that row has been realized, which is exactly when scrolling to it is unnecessary, and the fallback that brings a dropped row back by id then has nothing to aim at.
  • Keycaps use KeyCapChip: .outline (white-0.20 border) for hotkey hints on rows, .filled (white-0.10 fill) for footer shortcuts.

Section headers

All six palette lists (App Launcher, Clipboard, Emoji, File Search, Calculator History, Uninstall) render category labels through one shared SectionHeader (.subheadline.medium, secondary — Features/Launcher/UI/SectionHeader.swift). The launcher shows a single "Results" header over search matches, and per-kind sections (Favorites / Applications / System Settings / Commands) for the empty query; clipboard/history use date buckets (Today / Yesterday / …), and the clipboard adds a "Pinned" section above them holding every pinned entry (filtered searches included).

Spacing lives in Theme.Spacing: sectionHeaderBottom (header → first row) and sectionSpacing (gap above every header except the list's first, which reads as the previous section's closing padding). Each list passes isFirst: row.id == <rows>.first?.id so only the very first row skips the leading gap. Headers are non-selectable display rows, so selection (keyed by id) is unaffected.


Liquid Glass

Source: Theme.frosted(in:), DesignSystem/PopoverMenu.swift.

Glass is normally for floating controls. The dialog root is the one modal-surface exception.

  • View.frosted(in:) = glassEffect(.clear.interactive(), in:) — clear, interactive lensing. Used on the action-group capsule, the menu circle and PopoverMenu. Dialogs intentionally use untinted, non-interactive .glassEffect(.regular) on their root instead; HUDs retain the panel recipe (see "Dialogs & HUD"). Retune it in frosted(in:), not per call site.
  • Menus are in-window overlays, not system popovers. .contextMenu/NSMenu stall clicks for seconds inside a LazyVStack and spill outside the panel. Use PopoverMenu anchored to a corner via .overlay, inset menuInset (8pt) so its own corner isn't clipped by the panel's. A menu hung off a control instead of a corner — the clipboard type filter, .topTrailing — insets by that control's own metrics so their edges line up.
  • A menu's width is fixed, never intrinsic, so it can't jitter as its rows change. Every header menu states its own at its RootPaletteView.menuContent case — menuWidth 276, or a token of its own where that reads too wide (clipboardFilterMenuWidth, fileSearchFilterMenuWidth, emojiCategoryMenuWidth) — so retuning one never moves another. Native footer menus add 30pt without changing those header widths; extension Actions owns its nearby 310pt width inside the feature.
  • PopoverMenu uses glassEffect(.regular) with menuPanel 16 corners and no hand-tuned shadow — Tahoe glass carries its own elevation; adding a drop shadow reads heavy and non-native. A footer menu raises only its attached bottom corner to the controls' 18-point radius, so the two silhouettes meet exactly.
  • Its native search field is a row-height sibling of the scroller, above header menus or below footer menus. It uses an 18pt horizontal inset to align with the visible row glyphs. The top field stays vertically symmetric; the bottom field keeps its 1pt optical lift. Menus omit the adjacent edge dissolve and centre No Results in one row when their filtered rows are empty. The 8pt resting list inset belongs to the scroll content, so rows can reach the surface edges without shifting their initial position; hover fills keep the dedicated menuRow 10 corner.
  • PopoverMenuRow: leading glyph, label, trailing shortcut glyph and menuHover fill on hover. Menus animate in with opacity and scale from the anchored corner, stretching briefly to 1.003 before settling; Theme.MenuMotion owns the entry, settle and exit timings.
  • The glyph is a PopoverMenuIcon: .symbol (SF Symbol, monochrome, menuSymbol — or red when isDestructive) .file (a real app icon via IconCache, used by the paste rows to show the paste target) or .thumbnail (a picture's own preview, cropped to the slot, used by the chat's staged-file rows). PopoverMenuItem keeps a systemImage: convenience init, so symbol rows read exactly as before.
  • Every glyph kind shares one square menuIcon (20) slot, which pins one row height. A native SF Symbol uses the dedicated 14pt Medium menuSymbol font; file and brand icons keep their own artwork sizing inside the same slot, and a thumbnail fills it.
  • Menu rows use the md icon→label gap; the fixed slot adds the remaining optical slack.
  • A menu's rows are a LazyVStack, so opening one builds only the rows in view: the model menu runs to hundreds, and laying all of them out took seconds. The viewport's height is worked out from the row count, never measured, so nothing needs the rest. The rows hold no AppKit control, which is what keeps a lazy stack safe here (see Settings lists below).

Dialogs & HUD

Source: Windows/Dialog/, Windows/HUD/.

Tinycast owns its dialogs; NSAlert is never used. DialogController is owned by AppCore (the sole owner rule) and is the only presenter, so every confirmation in the app looks and behaves alike.

  • Three independent axes. The icon says what, the tone says how serious, the button role says what happens if you click. None of them derives another — that separation is the whole point of the design, and collapsing any two of them back together is a regression.
  • Icon. DialogRequest.symbol is required and is always the subject's own glyph: a system command passes its SystemAction.sfSymbol, so the Restart dialog shows arrow.clockwise and Empty Trash shows trash.slash — the same glyph as the launcher row the user just activated. Custom commands use terminal, the backup flows square.and.arrow.up / .down. Symbols render through SymbolImage (DesignSystem/SymbolImage.swift), never raw Image(systemName:), because some catalog symbols are bundled template assets rather than SF Symbols — toggleBluetooth ships its own artwork since the logo is a SIG trademark, and a raw Image(systemName:) draws nothing for it.
  • Tone. DialogTone is .neutral (secondary gray), .success (green) or .danger (red), and it tints the leading glyph and nothing else. The dialog tile lifts a neutral glyph to textSecondary for the same legibility as a key-cap symbol; semantic colours stay unchanged. .neutral stays gray rather than system blue on purpose, since a hue here should mark a state the way the other two do, not decorate an otherwise neutral message. There is no separate warning-vs-error case: both read equally severe and were only ever told apart by the icon's shape, which the action-derived icon now owns. MessageHUDController.show(message:tone:) (the pill; see below) takes the same DialogTone for its status dot, so the pill and the dialogs speak one tint vocabulary even though they render it differently. AppCore derives a system action's tone from SystemActionFeedback.isNoOp, so "Trash Emptied" reads .success and "Trash Is Already Empty" reads .neutral, rather than every pill defaulting to the same green dot regardless of whether anything happened.
  • Button role. DialogAction.Role colors the label: a default .standard action uses primaryAction, another .standard uses Color.primary, .destructive uses Theme.Colors.destructive, and .cancel uses textSecondary. Because role is independent of tone, a red-glyph security warning can carry a plain white button — "Import executable commands?" does, since importing a file destroys nothing — and running a shell command the user wrote themselves is .neutral + .standard rather than a red alarm.
  • Surface. A dialog applies one system .glassEffect(.regular) to a RoundedRectangle(panel 26). Confirmations and notices use dialogCompactWidth 290, including the volume slider; form dialogs use dialogWidth 420. panelScrim sits beneath the glass so the dialog keeps the launcher's darker density without losing the system material. DialogPanel clips its NSHostingView layer to the same continuous radius, then lets AppKit draw the native window shadow; this avoids both the rectangular outline of an unshaped panel and the hard bounds of a SwiftUI blur. The launcher's root gains a dialogDimming overlay only when it remains visible behind the panel; actions that hide the launcher first do no dimming work. Its fade follows dialogEnter / dialogExit so both surfaces separate as one transition. Matte button fills come from controlSurface / selection, while a destructive action uses the destructive tint. The volume HUD and message pill keep the non-glass panel recipe.
  • Layout. Subject glyph in a low-opacity rounded tile, then title (panelTitle) + wrapped secondary message, optional accessory and full-width actions. Content uses a 22-point inset while the actions keep the tighter dialogInset 18. One action spans the row; two sit side by side with Cancel rendered leading only while both labels fit on one line, then ViewThatFits stacks them. Three or more always stack in the caller's semantic order. DialogView.visualOrder reorders only horizontal display; onChoose(index) still dispatches against DialogRequest.actions' original order, so a caller never has to think about layout position when it builds a request. Every role uses the regular callout face; semantic roles change colour, never weight. dialogButtonHeight 34 is shared by the actions, dialog text fields and New Event segmented choices. The fields and segmented choices use the non-pill row 10 radius.
  • Keys. DialogPanel.sendEvent intercepts Esc and ↵ directly instead of relying on SwiftUI onKeyPress, so the keys work without anything inside the dialog holding focus. Buttons don't print a key cap; after tooltipDelay of deliberate hover, .tooltip(keyCap:) fades in the shared Tooltip tile holding an outlined KeyCapChip for the key the panel actually handles (↵, ⎋). Only those two keys are ever advertised, so a shown cap cannot drift from dialog behavior. ↵ runs the dialog's primary action; Escape cancels, on every dialog including destructive ones. Arrow keys walk the volume slider along the same 5% grid the volume commands use (DialogPanel reports .increment / .decrement and DialogController applies VolumeLevel.stepped, so the panel never learns what a volume step is); click-away resolves as a dismissal.
  • Async, not modal. Presentation is async (withCheckedContinuation), so there is no nested run loop. A held hotkey can't stack dialogs: while one is up, a second request resolves immediately as a dismissal — which is why the old isConfirmingCommand re-entrancy flag is gone. The guard is keyed on the live continuation, not on the panel, so a dialog still fading out can't swallow the next one.
  • Entrance and exit. A dialog starts 3pt below its final position at 8% opacity. AppKit moves the cached full-panel surface upward over dialogEnter (0.12s), with the alpha following the same curve so the fade masks the window's whole-pixel movement and the native shadow travels with the glass. There is no SwiftUI scale: scaling the content inside a full-size shadow produced a transient rim. Exit is an interruptible fadeOut over dialogExit (0.10s); its handler hides the window only if the alpha is still 0. The continuation resumes before that fade, so confirming Restart is never held up by animation. Other borderless surfaces retain the shared Duration.enter / Duration.exit timings and PanelTransition behavior.
  • Non-activating, like the palette: the dialog takes key focus for its own keys without pulling app focus off whatever the user was in. It sits at .dialog, above the palette's .palette, and is centred on the cursor's display with the same slight optical lift the palette uses.
  • VolumeSlider uses SwiftUI's native Slider, paired with a monospaced-digit percentage in the same volumeReadout 38 slot the HUD uses, so the row does not resize between 0% and 100%. Pointer interaction stays native; the arrows still walk the 5% grid through DialogPanel.
  • VolumeHUDController's box is the readout for the volume/mute commands, since macOS only draws its own HUD for real media keys and a CoreAudio change would otherwise be silent. It exists because a level needs an actual bar and number, not a one-line message: speaker glyph (dialogIcon 32, neutral Color.primary — a level isn't a success/warning statement), the bar, then the level as monospaced text beside it, in a fixed volumeReadout 38 slot so the track can't resize as the number runs 0% → 100% — the same trick VolumeSlider uses, since the two now read as one control in two places. That slot is measured, not guessed: 38 is the widest string it ever holds ("Muted", 36pt in rowTrailing) plus a hair, because every point of slack is subtracted straight off the track. Fixed hudWidth 200 × hudHeight 100, with asymmetric padding — xxl 20 vertical, xl 12 horizontal — since 20pt of side padding costs a fifth of a 200pt box where the same token on a 420pt dialog costs a twentieth, and the bar is the content here. Muted prints Muted, not 0%: the bar is already empty, so a number would either contradict it or hide the level the user comes back to. Auto-dismisses after Duration.volumeHUD (1.6s); a repeat command updates the shared VolumeState and calls HUDPresenter.extend(), so the bar slides to its new value in place instead of replaying the entrance.
  • MessageHUDController's pill is every other transient confirmation: Custom Commands and Snippets confirming a run, and every system action whose effect is invisible (Trash Emptied, Hidden Files Shown, Bluetooth Off). One capsule shape, sized to its message (hudMaxWidth 420 ceiling), clipped to a Capsule(), with the message first and a filled glyph trailing it: checkmark.circle.fill green for .success, exclamationmark.circle.fill red for .danger, info.circle.fill secondary for .neutral. Here the glyph is the tone — the one place that's true, because a pill has no subject to name the way a dialog does; the message already says what happened ("Trash Emptied"), so the icon only has to say how it went. The mapping is fileprivate in MessageHUDView.swift precisely so nobody can reach for it when building a DialogRequest, where the icon rule is the opposite. It trails rather than leads because a pill is read left to right and the outcome is the last thing you want to land on. Auto-dismisses after Duration.messageHUD (2.4s) — longer than the volume box, since a sentence needs reading time and a level only needs a glance — and a repeat call replaces rather than stacks.
  • The same pill reports work still running, through showProgress(message:onCancel:): a Quick Action set to replace has no panel to watch the answer arrive in, so the pill says Fixing Grammar… in its place and the result message replaces it when the model is done. Its trailing mark is a spinner rather than a tone, which is why MessageHUDView.Accessory exists — a tone says how something went, and nothing has gone anywhere yet. When onCancel is provided, hovering over the pill lights it up, turns the spinner into an xmark, and clicking anywhere on the pill cancels the in-flight task. The spinner is progress.indicator with .symbolEffect(.variableColor), never a ProgressView: AppKit draws that one itself and ignores every tint given to it, so a blue spinner is only reachable as a symbol. Progress has no natural dwell, so it is shown with dwells: false and stays up until something replaces it or HUDPresenter.dismiss() runs — the caller owns that, and QuickActionCoordinator.produce pairs the two with a defer so a throw or a cancellation cannot strand it.
  • HUDPresenter is what keeps those two controllers from duplicating each other: one panel at a time, replace rather than stack, fade in, sit out its dwell, fade away, centred horizontally on a screen. The two HUDs differ only in their content, their anchor (edgeInset(hudEdgeOffset 48) for the pill, heightFraction(0.12) for the box) and how long they dwell — so those are the presenter's three arguments. It sizes its window from a local, never from host.frame after attaching the content view: assigning a content view resizes it to the window's current content rect, which is zero on a fresh panel, and a zero-width window "centers" with its leading edge on the screen's midline — visible only on the session's first HUD, which is what makes it easy to miss. Add a third HUD by constructing another presenter, not by teaching an existing controller a second shape.

Scrollbars

Source: DesignSystem/Scrolling/ThinScrollbar.swift.

Custom thin overlay scrollbar (the native one flashes and reserves a gutter inside a transparent panel). .hideNativeScrollers() on the scroll content forces the backing NSScrollView to a hidden .overlay style; .thinScrollbar() on the scroll view draws a hairline thumb (Color.primary alpha 0.30 rest → 0.42 hover → 0.5 drag) that fattens on hover, with a faint rail revealed only while hovering/dragging.

Routing: the palette lists (App Launcher, Clipboard history, Emoji, File Search, Calculator history) use .thinScrollbar() + .hideNativeScrollers(); the Clipboard preview (right pane), every Settings pane and the update window take the native scroller as-is. Don't reintroduce native scrollers on the palette lists.

Native scrollers are overlay app-wide, set once. AppDelegate.applicationWillFinishLaunching writes AppleShowScrollBars = WhenScrolling into Tinycast's own defaults domain, which outranks the global one. Under the system's "Automatic" setting AppKit otherwise switches every scroll view to thick legacy scrollers the moment it sees a mouse — a scroll view is born overlay and flips ~half a second later, which read as a thick bar flashing at the right edge of each pane. There is no per-scroll-view shim: chasing that flip after the fact is what caused the flash.


The room preview

The one full-screen surface besides the drop guides, and like them a readout: one click-through, never-key panel per display at .paletteDropGuide, under the palette. It is the exception to "glass only on floating controls" in the other direction — the whole desk is NSVisualEffectView .fullScreenUI blurred behind the window and dimmed by roomPreviewDim, so only the cards read.

A card is a solid window-to-be, not a row: Theme.Radius.roomCard (16), roomCardFill in both appearances so the desk never shows through it, a roomCardStroke accent border, and one shadow. Its title bar carries three quiet dots, the app name (.headline) and the window title. The icon grows to roomCardIconLarge on a card larger than 320 pt both ways, hides on one shorter than 160, and moves out from under the palette.

Motion is Theme.RoomMotion.glide for a card that changes place, a fade for one that arrives or leaves, and fadeIn/fadeOut for the panels; Reduce Motion removes all of it. See features/window-rooms.md.

The camera preview panel

CameraPreviewPanel is the third borderless surface, beside the dialog and the notes panel. It takes the same recipe — panelScrim, then GlassEffectView, then the clip — and the same optical lift a dialog takes, but sits at .floating rather than .dialog so a failure report still lands on top of it.

AVCaptureVideoPreviewLayer is hosted in one NSViewRepresentable and nothing else; the title, countdown and buttons around it are Tinycast's own. Its buttons are a deliberate copy of DialogButton rather than a share: the dialog owns its button, and a preview that had to move with it would couple two unrelated surfaces.

Dialog accessories

A dialog carries at most one control beyond its buttons, and DialogAccessory makes that structural rather than a convention — .volume for the Set Volume prompt, .eventDraft for New Event, .snippetArguments for a snippet's {argument} values. Text fields take dialogTextField(); New Event groups its fixed start and duration values into two local segmented bars, while a snippet's inline enumerated arguments remain DialogChips. Two things follow from the enum:

  • Arrow keys belong to the accessory, not the panel. DialogPanel.handlesArrowKeys is set from DialogAccessory.claimsArrowKeys, so the slider still steps on ←/→ while the New Event title field keeps its caret.
  • An accessory can refuse its own primary action. An invalid draft leaves the dialog up on ↵ and on a click alike, which is what a greyed-out button would say if DialogAction could carry one.

Settings

Source: DesignSystem/SettingsComponents.swift.

Settings runs in its own resizable NSWindow (the SwiftUI Settings scene is unreliable for accessory apps) with real traffic lights and a lifecycle wholly its own. It does not share the palette's look: every pane is a stock Form with .formStyle(.grouped), so the cards, headers, row insets and hairlines are all system-drawn and a pane reads exactly as macOS System Settings does.

  • A row is a stock control. LabeledContent, Toggle or Picker, each with a two-view label — the first view is the title, the rest become the secondary subtitle. Never a hand-built HStack with its own padding.
  • A row with a custom trailing control uses SettingsRow, not LabeledContent. LabeledContent wraps its value in a selectable text field, which swallows the taps a ShortcutRecorder needs — the recorder renders but never starts recording. Stock Toggle/Picker/Button trailing content is unaffected.
  • .settingsEnabled(_:), never a bare .disabled(_:). It dims as well as disables, so a switched-off row reads as unavailable rather than merely unresponsive.
  • A segmented control that opens in a panel is a SteadySegmentedPicker. The system control opens tight around its labels and widens the first time its selection changes, under the pointer, and its own idea of its width shifts a few points with it. The wrapper pins each segment to its label plus segmentLabelInset and reports its size from those widths. A segmented Picker that is redrawn right after it appears, like the Providers panel's page control, settles before it is seen.
  • A control placed directly on an editor panel's glass loses its accent colour. Inside a grouped Form row it keeps it. A panel with such a control asks for settingsEditorPanelSurface(controlsOnGlass: false), which draws the same glass behind the content.
  • A secret is a RevealableSecureField, never a bare SecureField. One eye, one place, so an API key, a header value and a passphrase all offer the same way to check a pasted value before saving. It re-hides on its own once the field is cleared, and its eye is disabled while it is empty. A password field an extension declares, in a form or a preference, is left as it was.
  • A TextEditor ignores .disabled(_:) on macOS — its own and an ancestor's alike. The backing NSTextView keeps its caret, its keyboard and its selection, so a "disabled" prompt box still takes typing and still gives up its text to ⌘A ⌘C. Swap the editor for a Text when it must be read-only, the way SystemPromptEditor does; dimming an editor that still accepts input is the bug, not the fix.
  • A group is a Section, with header: for its name and footer: for the caption that used to ride under the last row.
  • Interface size and Emoji Skin Tone use settingsOptionSegment for the same square selection shape, while keeping their own content sizes.
  • A pane scans as section → setting → control, so its words are rationed. A subtitle is a short phrase, and only where the title leaves out a consequence or a limit ("Shortcuts still work when hidden."); a footer carries a caveat, such as privacy or cost, never a restatement of its header. A fact every list would repeat lives once, in a tooltip — launcherVisibilityHelp() on each launcher checkbox.
  • Settings is one SwiftUI NavigationSplitView (SettingsRootView), hosted with sceneBridgingOptions = [.toolbars, .title] so its toolbar, title and search field reach the AppKit window. It was an NSSplitViewController; in that sidebar every search bar drew a hard scroll edge with a hairline, which no scrollEdgeEffectStyle or accessory style could soften. .toolbar(removing: .sidebarToggle) goes before navigationSplitViewColumnWidth, or the column shrinks to AppKit's default thickness.
  • The pane's own title is not in the pane. .navigationTitle puts it in the titlebar beside the Back/Forward chevrons. SettingsWindowChrome installs before the content mounts: the bridged toolbar restores the title flags it mounted over, so a later titleVisibility = .visible is undone on the first navigation.
  • Settings and AI Chat are the windows that keep the system titlebar. AppWindowController builds every window with titlebarAppearsTransparent = true, which opts the titlebar out of the system's glass band; SettingsWindowChrome.install(in:) and AIChatWindowChrome.install(in:) set it back to false, so the band and its scroll edge effect are drawn by AppKit as a pane's Form or the transcript scrolls under it. .fullSizeContentView and titlebarSeparatorStyle = .none stay — the content still runs under the bar, and a hairline would split the surface the band unifies. Both also clear isMovableByWindowBackground: stock Settings isn't dragged by its content, and a drag across a transcript selects text. Onboarding, Updates, Support and Command Output keep the transparent titlebar they were tuned for. Never hand-draw a header band; a main surface takes the system's material, not glassEffect.
  • SettingsComponents.swift holds only what more than one pane or editor needs: SettingsRow, SettingsTabIcon (the sidebar tile reused by feature switches), SettingsFeatureToggleLabel (the icon, title and subtitle of a feature's master switch), FeatureSwitchSection (a feature's master switch plus its launcher-visibility companion), SettingsFilterField (the filter row above a long list), launcherVisibilityHelp(), and the Settings editor header, fields and surface. ModalActionButtonStyle.swift keeps every borderless surface's actions on one implementation — dialogs, Settings editors, the camera footers and the Quick Action panel. Onboarding/OnboardingCard.swift keeps the older hand-drawn card, which that window still uses.
  • A Settings editor borrows the dialog language, not its job. SettingsEditorPresenter hosts the existing form in an activating, transparent child NSPanel, with the same panel 26 Liquid Glass surface, 3pt/8% entrance and matte action buttons. A blocking child covers and dims the whole parent, including its titlebar; nested editors form one stack owned by the Settings-window session. The presenting binding remains the dismissal source of truth, while launcher handoffs are consumed into pane-local state so opening an editor does not repaint the split view. A list that can keep growing scrolls at a stated row count instead — Custom Commands caps its arguments at visibleArgumentRows — so a panel's height stays a property of the editor, not of what has been typed into it. Extension editor visuals stay inside Features/Extensions/; the shared presenter treats them as opaque content.
  • The sidebar searches every pane and its rows. .searchable(placement: .sidebar) sits above the list and swaps it for a flat, ranked result list; each result carries the pane's systemImage, the row's title and a Pane › Section breadcrumb, and arrowing through them moves the pane, as System Settings does. Selection runs through SettingsNavigationState.select, so a result is an ordinary navigation the Back/Forward chevrons can walk. A row result also reveals its section: the pane scrolls the matched setting to centre and pulses its own name once (Colors.searchFlash) before settling; a result no single row answers pulses the section's header instead. It is a second List, keyed by SettingsSearchEntry.ID, so result identities never share a selection namespace with SettingsTab. ⌘F focuses the field, Escape clears it.
  • SettingsSearchCatalog is hand-written, and that is the only option. A Form cannot be asked what rows it holds, so a new row is searchable only once it is listed there — with its anchor, its title and the keywords the title doesn't contain ("caps lock" for Hyper Key). Matching reuses FuzzyMatch (Launcher/Model/SearchRelevance.swift), multi-term like NoteSearch: every term must hit the title, the breadcrumb or a keyword, a title hit outranks the rest, and a pane outranks its own rows so a bare "clipboard" lands on the pane. Tests/settings-history-test.swift pins that every pane is covered and that identities are unique; row-level drift is caught in review. Match ranges are deliberately not highlighted — FuzzyMatch returns tier and score only.
  • SettingsAnchor names a section once, so the catalog and the pane cannot disagree. An entry takes its pane from the anchor, so a row filed under the wrong pane won't compile. SettingsSectionHeader(_:) in a section's header: supplies the name and the id a group result scrolls to; SettingsRowTitle(_:_:) stands in for the Text of a row's own label and does the same for one setting. A section with no header of its own takes .settingsAnchor(_:), an id and nothing else. A catalog entry is .init(anchor, title) for one row and .init(group:_:) when no single row answers it — a list, a section's master switch, a button that lives in another row's trailing edge. The half the compiler can't reach — a target nothing declares, which navigates and then sits there — is caught by Scripts/check-settings-search.js, run from Scripts/lint.sh. Add the entry and its marker together, or lint will say so.
  • The reveal lives in two modifiers, never in a pane (SettingsScrollTarget.swift). .settingsScrollTarget() on a pane's Form holds the ScrollViewReader and one .task(id:) keyed on the request, so a second jump cancels the first mid-pulse and the pane releases the request when it settles; .settingsAnchor(_:) on a Section supplies the .id and paints the wash, reading which anchor is lit from \.settingsFlash rather than having it threaded down. SettingsScrollRequest carries a token because picking the same result twice has to scroll again rather than compare equal and do nothing.
  • The pulse is a pill on the name, and nothing around it is touched. A grouped Form applies a .background to a row's whole content box, so lighting a section — or a row — paints ragged blocks at the width of every label, button and footer paragraph in it. (.listRowBackground is a no-op here, at section and at row level, and .overlay strokes every row separately.) Putting the pill on the label instead is one fixed shape nothing can render badly, it leaves each row's subtitle and control alone, and it marks exactly the words the result row showed.
  • The light is the window's, not the pane's (SettingsNavigationState.flashing). Both panes are briefly alive across a swap, so a pane-local @State pulse dies with the pane that lit it, and an @Environment value doesn't reliably repaint an already-realized Form row. Two rules fall out: a cancelled reveal returns without ending the pulse, since cancellation means a later jump owns the light now; and scrollRequest is released only once the pulse is over, because it keys the pane's .task(id:) and clearing it early cancels the very task doing the revealing.
  • The sidebar's field is the system's, not SettingsFilterField. That one is borderless because it lives inside a Form row; the sidebar's is .searchable, which AppKit seats as a split-item accessory with a soft scroll edge — the list blurs out under it, as in System Settings.
  • A Form realizes every row it is handed, and a lazy stack rebuilds a row's AppKit controls. Handed 400 apps directly, a Form took 750 ms and 2040 views; a LazyVStack in one Form row fixed that, but tears a row's TextField and checkbox — both NSViews — down when the row scrolls off and builds them again when one scrolls on, about 7 ms and 4 ms on macOS 27. A fast scrollbar drag replaces a screenful of rows per update, so the list froze for 100–400 ms at a time. The Applications and Apple Shortcuts lists therefore use LauncherItemsTable, an NSTableView filling one Form row; the shorter launcher-item lists use native Form rows. The table keeps a screenful of cells and hands each a new entry, and each cell hosts the SwiftUI LauncherItemRow, so a reused row's controls update in place. A hosted row inherits nothing from the pane, so the table injects the stores the row reads, and moves Tab on to the next row's alias field itself; rows are a fixed 45 pt to match the native Form rows. A negative .padding doesn't move an AppKit view, so the table hangs 11 pt into the Form row's padding at the top (including the search divider) and 10 pt at the bottom, matching native row origins without adding space after the last row. SettingsListMetrics keeps row icons at one size, and SettingsScopeRow renders folder and application scope icons consistently across pages. A long list whose rows hold no AppKit control can stay a LazyVStack.

The window-layout editor

Theme.Size.layoutEditorSheet is 900 × 660, both stated. The width less layoutInspectorColumn (300) leaves the preview two thirds of the panel, and the canvas is greedy inside it — a fixed preview box would spend that third on margin. The height is stated because the inspector grows: picking an app reveals Argument, Size, Offset and Position, and an intrinsic panel would jump out from under the pointer mid-click. The inspector scrolls if it ever overflows.

Every inspector control — text field, dropdown, add button — is one layoutFieldChrome: a Radius.barControl rounded rect at layoutControlHeight, cardFill on cardStroke, accent-stroked while focused. A numeric field fills its half of the row rather than sizing to a stated width, so a value can never be cropped, and layoutFieldUnit keeps "%" and "pt" on one x.

The entry dropdown is a button and a popover, not a Menu: a menu label stretches an NSImage out of aspect, which is what made the app icon smear. The rest of the app already picks apps this way (AppPickerPopover).

In the position grid the glyph floats in a wider cell carrying the contentShape, so a click anywhere in the cell lands — a bare stroke is hittable only on the line itself. Each anchor's block takes half a pinned axis and all of a spanned one, which is what makes nine cells nine silhouettes rather than nine identical rectangles.

layoutPreviewGround is adaptive, never ramp: a drawn display is dark in both appearances, and a ramp would invert it in Light. layoutPreviewWindow is white in both for the same reason — it sits on that always-dark plate. Radius.glyph (2) exists because thumbnail rounds a 10 pt square into a circle.

The Save button draws a ⌘ ↵ cap, which the no-caps-on-buttons rule above otherwise forbids. That rule guards against a printed cap drifting from what DialogPanel.sendEvent handles separately; here the cap and the behaviour come from one .keyboardShortcut, so the drift is structurally impossible. See features/window-layouts.md.

The shortcut recorder callout

ShortcutRecorder is a 120pt field showing only the binding — a combo's modifiers collapse into one cap (HotKeyBinding.compactKeycaps), so any shortcut fits in two chips. Recording is narrated by ShortcutRecorderPopover, a small 132 × 82 callout above it: caps, one label line, an esc cap in the top-left corner. Its fixed frame shows the prompt (⌥ A at half opacity, "Type a shortcut"), live held keys, a pending second Globe tap, or a conflict (rejected caps + owner, orange).

  • An ancestor draws it. The open recorder publishes its bounds via ShortcutRecorderAnchorKey; .shortcutRecorderPopoverHost() sits on SettingsDetailView — one host above every pane's Form, and on OnboardingView. An overlay on the row would be clipped by the scroll view. A recorder in a LauncherItemsTable cell sits in its own hosting view, where the preference stops, so the cell reports the recorder's frame and LauncherItemsSection republishes it as the anchor.
  • shortcutPopover.width is load-bearing. The callout centres on the recorder only while it fits either side of it; wider than that and the clamp kicks in and skews the caret. Tests/callout-test.swift pins this.
  • One glass shape. CalloutShape (HotKeys/UI/) draws body and caret as a single path so glassEffect lenses them together. The caret is two straight edges meeting at an arc — a rounded-tip triangle, not a dome. Stock .regular glass, no hand-tuned shadow, as in PopoverMenu.
  • Placement is pure. CalloutPlacement (HotKeys/UI/) picks above-vs-below, clamps, and walks the caret; the harness compiles it against the real Theme so a retuned token can't outdate the assertions.
  • KeyCapChip.Scale is compact / standard / hero — three tokenised sizes, no stray frames.
  • allowsHitTesting(false): clicks reach the capture session's mouse monitor; a click on the active recorder toggles it off, and a click elsewhere closes it.

The calculator's inline CalculatorCard reuses this card language (cardFill + cardStroke) rather than the row language, since it's a highlighted answer, not a list item. A value answer is a two-column layout: a source column (input echo) and a target column (result), separated by a centered arrow.right glyph (no divider line). LeadCardColumn is that column, pill included, so the colour card is built from the same part rather than a copy of it. Each column optionally carries a word-name badge pill beneath its value (keyCap font, controlSurface fill, keyCap radius) — Expression→Result for scalar arithmetic, unit or currency names for typed results (Expression→Kilograms), and moment labels for a date/time calc (12:18 AM→9:00 AM, Friday, 24 July→Friday, 9 April, 2027). A trailing operator keeps the last complete result and its badge visible while the next operand is being typed.


The palette search field

Its placeholder is drawn by Tinycast, not by the field's prompt — an NSTextField renders a prompt through either its cell or its (one point taller) field editor, so a real prompt steps vertically when focus moves. Don't reintroduce prompt: on that field. Drawing it costs one thing the real prompt gets free: it must be gated on PaletteState.isComposing as well as an empty query, or it sits under an IME's marked text. See features/palette.md.

Rules for agents working on the UI

  • Restyle from rendered screenshots, not guessed values. Compare rendered screenshots over a light desktop. There's no screen-recording from the shell here — verify AppKit rendering with a swiftc harness that prints layer state, and let the user do visual sign-off.
  • Don't add behavior that wasn't requested. A restyle changes appearance, not interaction — keep selection/scroll/dismiss/focus flows exactly as they are unless the task is about them.
  • New tokens go in Theme, referenced everywhere. No magic numbers in views.
  • Keep the shared grammar shared. If you change row insets, the fill precedence, section-header style, or keycap style, change it for all lists — divergence is the bug, not the feature.
  • Build & verify with the real toolchain (see development.md); a design change that doesn't compile under Swift 6 mode isn't done.