With a Settings editor open, nothing could move the Settings window: the editor panel was not draggable, and the dim layer covered the window's titlebar and swallowed every press. Now a drag on an editor's empty space moves the Settings window, as a sheet's does, and the editor follows it as a child window. The editor surface puts a drag view behind its content, so controls keep their clicks, and the panel hands performDrag(with:) up to its parent, so nested editors move the same window. A drag on the dim layer moves it too.
77 KiB
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:
- Surface = scrim over behind-window blur. No solid backgrounds. Depth comes from the desktop showing through.
- One alpha ramp, never grays. Ink at fixed stops — white over the dark surface, black over the light one.
- Floating bars, not chrome. Header/footer are transparent overlays; the list fills the whole panel.
- Edges dissolve, they don't clip. Scroll-driven mask, no separators between list and bars.
- 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.Colorstoken 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, fromAppSettings.appearance;.systemassignsnilso AppKit follows macOS. - New colors go through
Theme.Colors.ramp(dark:light:)(an alpha that inverts) oradaptive(dark:light:)(two explicitNSColors, for anything that isn't a plain inversion —panelScrim,layoutPreviewGround). Never a bareColor.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/OverflowFadegradients are mask luminance, not color — inverting them breaks the dissolve everywhere.ExtensionTintColorsand a tintedIconCachetile keep white ink, because a saturated tile carries its own contrast. AndIconCachecannot use a dynamicNSColorat 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
NSWorkspacehands out when System Settings → Appearance → Icon & widget style changes, soIconStyleMonitorand Tinycast's own appearance both callIconCache.invalidateStyled(). The monitor may not invalidate on the notification itself. AppKit postsNSWorkspaceIconAppearanceConfigurationDidChangebefore IconServices has swapped whatNSWorkspacevends — 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.IconStyleMonitortherefore pollsIconCache.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 movesIconCache.style.generation. Any view that draws an icon must key its fetch on that generation — wrap the view's own key inIconRequest, or callIconCache.observeStyle()where the icon is resolved synchronously in abody. It is reached throughIconCacherather 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
safeAreaInsetoverlays with no background; separation comes fromedgeDissolve(), 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.bodyends 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 gradientmaskwhere a surface owns its own fade —scrollEdgeEffectStyledraws a material where a scroll view meets a safe area, so over a panel that already haspanelScrim+GlassEffectViewit composites to nothing. Tried and rejected onQuickActionResultView, with and withoutsafeAreaBar. This is a rule about the borderless panels; the Settings window is a titledNSWindowwhose 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
NSAlertor 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 itsrunModalrun 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 itsSystemAction.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 fromDialogAction.Role(.standardwhite /.destructivered /.cancelsecondary), so a red-glyph security warning can still carry a plain white button — as "Import executable commands?" does. - Resolve every glyph through
SymbolImage, notImage(systemName:). Some catalog symbols are bundled assets inAssets.xcassets(toggleBluetooth), andImage(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
KeyCapChipin aTooltip. - 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 passesedge: .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 throughMessageHUDController's pill, whose leading glyph is itsDialogTone. 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 aDialogRequest. 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
PopoverMenuuse 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 lighterpanelScrim→GlassEffectView()→clipShaperecipe.
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; the xl gap after it starts the query on the row titles' column.
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 · resultRowIcon 26 · 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.
PalettePanelis a borderlessNSPanel:isOpaque = false,backgroundColor = .clear,.palettelevel (one above.modalPanel, so other apps' open panels never cover it),hasShadow,animationBehavior = .none. It hosts SwiftUI viaNSHostingView.PaletteWindowControllercenters it slightly above screen center (+8%) and dismisses it onwindowDidResignKey.- 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 plainTextField(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, and the query with the row titles. - 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 glassCapsuleholding the primary-action pill (label +↵) and the Actions toggle (⌘K). BarButtonis the shared bar control: bare label at rest, arowHovercapsule on hover,barButtonHeight 28. SetisSelectedand it fills withselectioninstead, which beats hover; the Notes formatting bar lights its buttons this way. SetisCompactforsmpadding instead ofmd: 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 26pt icon/thumbnail, title (.body,lineLimit(1)), optional trailing keycaps/kind label,Spacer. Insets:.horizontal md,.vertical sm.- The palette result slot is always
Theme.Size.resultRowIcon, whatever fills it. A glyph smaller than an app icon — the uninstall list's 16pt checkbox — is centred inside that 26pt 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. Settings and compact favorites keep the existing 24ptrowIcon. - Clipboard thumbnails use
IconCache.appIconExtentwithin that slot; synthetic row symbols use the same app-icon-shaped tile as the launcher. - Background is a
RoundedRectangle(row, .continuous)filled byfill: selection → hover → clear, in that precedence. Thisfillcomputed property is copy-identical acrossAppRow,ClipboardRowandUninstallRow— 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, soPaletteState.hoverDisarmTokenclears 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.swiftpins 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..topscrolls to the origin anchor thatscrollOriginAnchor()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.followthat 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'slandingSelectionthroughRootPaletteView.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. .followis an invariant, not a command (scrollFollowsSelection,DesignSystem/Scrolling/). Each list marks its selected row withselectionFrame(_:), 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, whichonScrollPhaseChangecannot 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 simply0…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 byTests/scroll-reveal-test.swift) and SwiftUI performs the move with an explicit.top/.bottomanchor. 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-shotscrollTohere 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 toscrollFollowsSelectionmust be the lazy container's ownForEachidentity — 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 andPopoverMenu. Dialogs intentionally use untinted, non-interactive.glassEffect(.regular)on their root instead; HUDs retain the panel recipe (see "Dialogs & HUD"). Retune it infrosted(in:), not per call site.- Menus are in-window overlays, not system popovers.
.contextMenu/NSMenustall clicks for seconds inside aLazyVStackand spill outside the panel. UsePopoverMenuanchored to a corner via.overlay, insetmenuInset(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
widthis fixed, never intrinsic, so it can't jitter as its rows change. Every header menu states its own at itsRootPaletteView.menuContentcase —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. PopoverMenuusesglassEffect(.regular)withmenuPanel 16corners 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 10corner. PopoverMenuRow: leading glyph, label, trailing shortcut glyph andmenuHoverfill on hover. Menus animate in with opacity and scale from the anchored corner, stretching briefly to 1.003 before settling;Theme.MenuMotionowns the entry, settle and exit timings.- The glyph is a
PopoverMenuIcon:.symbol(SF Symbol,monochrome,menuSymbol— or red whenisDestructive).file(a real app icon viaIconCache, 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).PopoverMenuItemkeeps asystemImage: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 MediummenuSymbolfont; file and brand icons keep their own artwork sizing inside the same slot, and a thumbnail fills it. - Menu rows use the
mdicon→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.symbolis required and is always the subject's own glyph: a system command passes itsSystemAction.sfSymbol, so the Restart dialog showsarrow.clockwiseand Empty Trash showstrash.slash— the same glyph as the launcher row the user just activated. Custom commands useterminal, the backup flowssquare.and.arrow.up/.down. Symbols render throughSymbolImage(DesignSystem/SymbolImage.swift), never rawImage(systemName:), because some catalog symbols are bundled template assets rather than SF Symbols —toggleBluetoothships its own artwork since the logo is a SIG trademark, and a rawImage(systemName:)draws nothing for it. - Tone.
DialogToneis.neutral(secondary gray),.success(green) or.danger(red), and it tints the leading glyph and nothing else. The dialog tile lifts a neutral glyph totextSecondaryfor the same legibility as a key-cap symbol; semantic colours stay unchanged..neutralstays 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 sameDialogTonefor its status dot, so the pill and the dialogs speak one tint vocabulary even though they render it differently.AppCorederives a system action's tone fromSystemActionFeedback.isNoOp, so "Trash Emptied" reads.successand "Trash Is Already Empty" reads.neutral, rather than every pill defaulting to the same green dot regardless of whether anything happened. - Button role.
DialogAction.Rolecolors the label: a default.standardaction usesprimaryAction, another.standardusesColor.primary,.destructiveusesTheme.Colors.destructive, and.cancelusestextSecondary. 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+.standardrather than a red alarm. - Surface. A dialog applies one system
.glassEffect(.regular)to aRoundedRectangle(panel 26). Confirmations and notices usedialogCompactWidth 290, including the volume slider; form dialogs usedialogWidth 420.panelScrimsits beneath the glass so the dialog keeps the launcher's darker density without losing the system material.DialogPanelclips itsNSHostingViewlayer 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 adialogDimmingoverlay only when it remains visible behind the panel; actions that hide the launcher first do no dimming work. Its fade followsdialogEnter/dialogExitso both surfaces separate as one transition. Matte button fills come fromcontrolSurface/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 tighterdialogInset 18. One action spans the row; two sit side by side with Cancel rendered leading only while both labels fit on one line, thenViewThatFitsstacks them. Three or more always stack in the caller's semantic order.DialogView.visualOrderreorders only horizontal display;onChoose(index)still dispatches againstDialogRequest.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 34is shared by the actions, dialog text fields and New Event segmented choices. The fields and segmented choices use the non-pillrow 10radius. - Keys.
DialogPanel.sendEventintercepts Esc and ↵ directly instead of relying on SwiftUIonKeyPress, so the keys work without anything inside the dialog holding focus. Buttons don't print a key cap; aftertooltipDelayof deliberate hover,.tooltip(keyCap:)fades in the sharedTooltiptile holding an outlinedKeyCapChipfor 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 (DialogPanelreports.increment/.decrementandDialogControllerappliesVolumeLevel.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 oldisConfirmingCommandre-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 interruptiblefadeOutoverdialogExit(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 sharedDuration.enter/Duration.exittimings andPanelTransitionbehavior. - 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. VolumeSlideruses SwiftUI's nativeSlider, paired with a monospaced-digit percentage in the samevolumeReadout 38slot the HUD uses, so the row does not resize between0%and100%. Pointer interaction stays native; the arrows still walk the 5% grid throughDialogPanel.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, neutralColor.primary— a level isn't a success/warning statement), the bar, then the level as monospaced text beside it, in a fixedvolumeReadout 38slot so the track can't resize as the number runs 0% → 100% — the same trickVolumeSlideruses, 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 inrowTrailing) plus a hair, because every point of slack is subtracted straight off the track. FixedhudWidth 200 × hudHeight 100, with asymmetric padding —xxl20 vertical,xl12 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 printsMuted, not0%: the bar is already empty, so a number would either contradict it or hide the level the user comes back to. Auto-dismisses afterDuration.volumeHUD(1.6s); a repeat command updates the sharedVolumeStateand callsHUDPresenter.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 420ceiling), clipped to aCapsule(), with a plain glyph leading the message:checkmarkgreen for.success,exclamationmarkred for.danger,infosecondary for.neutral. The glyph's tone also lights the glass — a faint radial glow from behind it and a hairline rim that fades across the message, the same treatment an extension toast restates in its own feature. 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 isfileprivateinMessageHUDView.swiftprecisely so nobody can reach for it when building aDialogRequest, where the icon rule is the opposite. It leads, as an extension toast's does, so the outcome lands at a glance before the sentence is read. Auto-dismisses afterDuration.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 saysFixing Grammar…in its place and the result message replaces it when the model is done. Its leading mark is a spinner rather than a tone, which is whyMessageHUDView.Accessoryexists — a tone says how something went, and nothing has gone anywhere yet. WhenonCancelis provided, hovering over the pill lights it up, turns the spinner into anxmark, and clicking anywhere on the pill cancels the in-flight task. The spinner isprogress.indicatorwith.symbolEffect(.variableColor), never aProgressView: 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 withdwells: falseand stays up until something replaces it orHUDPresenter.dismiss()runs — the caller owns that, andQuickActionCoordinator.producepairs the two with adeferso a throw or a cancellation cannot strand it. HUDPresenteris 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 fromhost.frameafter 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.handlesArrowKeysis set fromDialogAccessory.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
DialogActioncould 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,ToggleorPicker, each with a two-view label — the first view is the title, the rest become the secondary subtitle. Never a hand-builtHStackwith its own padding. - A row with a custom trailing control uses
SettingsRow, notLabeledContent.LabeledContentwraps its value in a selectable text field, which swallows the taps aShortcutRecorderneeds — the recorder renders but never starts recording. StockToggle/Picker/Buttontrailing 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 plussegmentLabelInsetand reports its size from those widths. A segmentedPickerthat 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
Formrow it keeps it. A panel with such a control asks forsettingsEditorPanelSurface(controlsOnGlass: false), which draws the same glass behind the content. - A secret is a
RevealableSecureField, never a bareSecureField. 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
TextEditorignores.disabled(_:)on macOS — its own and an ancestor's alike. The backingNSTextViewkeeps 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 aTextwhen it must be read-only, the waySystemPromptEditordoes; dimming an editor that still accepts input is the bug, not the fix. - A group is a
Section, withheader:for its name andfooter:for the caption that used to ride under the last row. - Interface size and Emoji Skin Tone use
settingsOptionSegmentfor 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 withsceneBridgingOptions = [.toolbars, .title]so its toolbar, title and search field reach the AppKit window. It was anNSSplitViewController; in that sidebar every search bar drew a hard scroll edge with a hairline, which noscrollEdgeEffectStyleor accessory style could soften..toolbar(removing: .sidebarToggle)goes beforenavigationSplitViewColumnWidth, or the column shrinks to AppKit's default thickness. - The pane's own title is not in the pane.
.navigationTitleputs it in the titlebar beside the Back/Forward chevrons.SettingsWindowChromeinstalls before the content mounts: the bridged toolbar restores the title flags it mounted over, so a latertitleVisibility = .visibleis undone on the first navigation. - Settings and AI Chat are the windows that keep the system titlebar.
AppWindowControllerbuilds every window withtitlebarAppearsTransparent = true, which opts the titlebar out of the system's glass band;SettingsWindowChrome.install(in:)andAIChatWindowChrome.install(in:)set it back tofalse, so the band and its scroll edge effect are drawn by AppKit as a pane'sFormor the transcript scrolls under it..fullSizeContentViewandtitlebarSeparatorStyle = .nonestay — the content still runs under the bar, and a hairline would split the surface the band unifies. Both also clearisMovableByWindowBackground: 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, notglassEffect. SettingsComponents.swiftholds 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.swiftkeeps every borderless surface's actions on one implementation — dialogs, Settings editors, the camera footers and the Quick Action panel.Onboarding/OnboardingCard.swiftkeeps the older hand-drawn card, which that window still uses.- A Settings editor borrows the dialog language, not its job.
SettingsEditorPresenterhosts the existing form in an activating, transparent childNSPanel, with the samepanel 26Liquid Glass surface, 3pt/8% entrance and matte action buttons. A blocking child covers and dims the whole parent, including its titlebar, so a press on it drags the parent; nested editors form one stack owned by the Settings-window session. As with a sheet, a drag on an editor's empty space moves the Settings window: the surface putsWindowDragBackgroundbehind its content, and the panel handsperformDrag(with:)up to its parent. 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 atvisibleArgumentRows— so a panel's height stays a property of the editor, not of what has been typed into it. Extension editor visuals stay insideFeatures/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'ssystemImage, the row's title and aPane › Sectionbreadcrumb, and arrowing through them moves the pane, as System Settings does. Selection runs throughSettingsNavigationState.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 secondList, keyed bySettingsSearchEntry.ID, so result identities never share a selection namespace withSettingsTab. ⌘F focuses the field, Escape clears it. SettingsSearchCatalogis hand-written, and that is the only option. AFormcannot 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 reusesFuzzyMatch(Launcher/Model/SearchRelevance.swift), multi-term likeNoteSearch: 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.swiftpins that every pane is covered and that identities are unique; row-level drift is caught in review. Match ranges are deliberately not highlighted —FuzzyMatchreturns tier and score only.SettingsAnchornames 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'sheader:supplies the name and the id a group result scrolls to;SettingsRowTitle(_:_:)stands in for theTextof 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 byScripts/check-settings-search.js, run fromScripts/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'sFormholds theScrollViewReaderand 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 aSectionsupplies the.idand paints the wash, reading which anchor is lit from\.settingsFlashrather than having it threaded down.SettingsScrollRequestcarries 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
Formapplies a.backgroundto 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. (.listRowBackgroundis a no-op here, at section and at row level, and.overlaystrokes 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@Statepulse dies with the pane that lit it, and an@Environmentvalue doesn't reliably repaint an already-realizedFormrow. Two rules fall out: a cancelled reveal returns without ending the pulse, since cancellation means a later jump owns the light now; andscrollRequestis 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 aFormrow; 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
Formrealizes every row it is handed, and a lazy stack rebuilds a row's AppKit controls. Handed 400 apps directly, aFormtook 750 ms and 2040 views; aLazyVStackin one Form row fixed that, but tears a row'sTextFieldand checkbox — bothNSViews — 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 useLauncherItemsTable, anNSTableViewfilling 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 SwiftUILauncherItemRow, 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.paddingdoesn'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.SettingsListMetricskeeps row icons at one size, andSettingsScopeRowrenders folder and application scope icons consistently across pages. A long list whose rows hold no AppKit control can stay aLazyVStack.
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 onSettingsDetailView— one host above every pane'sForm, and onOnboardingView. An overlay on the row would be clipped by the scroll view. A recorder in aLauncherItemsTablecell sits in its own hosting view, where the preference stops, so the cell reports the recorder's frame andLauncherItemsSectionrepublishes it as the anchor. shortcutPopover.widthis 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.swiftpins this.- One glass shape.
CalloutShape(HotKeys/UI/) draws body and caret as a single path soglassEffectlenses them together. The caret is two straight edges meeting at an arc — a rounded-tip triangle, not a dome. Stock.regularglass, no hand-tuned shadow, as inPopoverMenu. - Placement is pure.
CalloutPlacement(HotKeys/UI/) picks above-vs-below, clamps, and walks the caret; the harness compiles it against the realThemeso a retuned token can't outdate the assertions. KeyCapChip.Scaleiscompact/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
swiftcharness 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
fillprecedence, 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.