Skip to content

Settings UI Patterns

The cross-cutting control conventions of KiwiDesk’s Settings app, for anyone building or reviewing a Settings surface. The rulings behind them are in Design decisions; the GUI north-star they serve (simplicity, intuitiveness, Apple-native feeling, in that order) is defined in AGENTS.md §2. The layout-editor entries are here because they span all seven layout editors, not one pane.

Per-field help is a click popover behind a ? right after the label, not a hover tooltip (#94). A row that warrants a sentence of explanation carries a small questionmark.circle button immediately after the field’s label text, inside the shared settingsLabelColumn — where the question is born, and where System Settings puts its own info glyph. labelColumn holds the longest label plus the glyph; a long label + glyph truncates visibly (lineLimit(1)), and long German labels on help rows are shortening candidates for the de review pass. An unlabeled SegmentedPicker (icon tabs) has no label to sit beside, so its ? trails the track. The button wears the shared iconHoverChip like every other icon-only borderless control.

Clicking opens a fixed-width popover; .help() rides along as a hover fallback carrying the full text, while the VoiceOver hint stays a short action phrase (“Shows an explanation of this setting”), since the content is read inside the popover after activation. A popover is a real focusable button that keyboard and VoiceOver reach; a hover tooltip is neither. .help() remains the idiom for one-line hints on ambiguous icon-only controls. A field with 2–3 named options folds per-option text into the ONE field-level popover (option name bold, one line each) — never a ? per segment. Two scope guards: help is optional reading (must-know information never lives only in the popover), and a field already taught by its live preview or schematic (the bar colours, a layout card’s geometry) gets no ? at all.

A concept goes in the ?; a live fact goes in the flow. A popover’s copy is fixed for every reader, so it holds what is always true of the setting — what the thing is, what turning it off costs. A statement that turns on the current value of something else (“the Space Bar is off right now”) belongs in a caption or a CrossReferenceRow that re-renders with the state. Deleting a live cue does not move its fact into the ?: check whether the timeless half was ever written there.

Copy is a normal L() string under the <key>.help suffix convention. When a label key is shared by fields with divergent semantics, the help key scopes itself by the field (<key>.<layout>.help). Help copy rendered on two surfaces — a Layout Defaults layout card and the per-space override editor — is authored once in a per-domain namespace (LayoutHelp); membership means 2+ call sites or an override pair like newWindowPlacement/trackPosition, and single-call-site copy stays inline. In the per-space override editor the ? is rendered by OverrideChrome itself, not the wrapped row, so it stays clickable while the row inherits; it therefore sits at the chrome row’s trailing edge, consistently for every override row — the one exception to label-adjacent placement. Do not move it back inside the row: that re-enters the disabled scope.

“Lives elsewhere” pointers are links, and the link sits where the sentence puts it. Prose that names another tab (“configured in the App Bar tab”) is a dead end; such pointers are CrossReferenceRows, jumping the pushed-area selection through an injected settingsNavigate environment action.

The destination’s name is a positional specifier in the prose key, filled with CrossReferenceRow.linkSlot, so a translation may place it wherever its own word order wants; Text(prose) followed by a sibling link parks the name at the end and forces every key to be authored dangling (”… — edit them in”), the same defect as a +-concatenated fragment.

Resting style is an underline in the caption’s own secondary grey — never the system link blue, since these are prose the reader may follow rather than calls to action — lifting to primary under the pointer, linkHover()’s treatment for the tree’s other inline links (the make-default link, the rename pencil). A CrossReferenceRow renders through LinkedCaption, an NSTextView bridge: SwiftUI’s Text gives a .link run no pointing-hand cursor, and NSLayoutManager breaks lines correctly around a name mid-sentence in a language that does not break on spaces. That view owes its own keyboard activation, focus ring and accessibility child, which a Button gives free and an NSTextView does not.

A pointer whose sentence names a location takes a breadcrumb headed by the destination’s own title (“KiwiShelf ▸ App Bar”), not the section name alone: a link reading “App Bar” names no card Home shows, and only a ▸-shaped value enters SidebarCrossReferenceTests. A pointer whose sentence names the feature itself — a sentence turning on whether the Space Bar is on, say — links that mention and stays one segment, since a breadcrumb there would be a second mention. Every shipped CrossReferenceRow call site names a location.

Segmented vs. menu is decided by a rule, not per row (#291). A pick-one control is a SegmentedPicker when the choices are a fixed set of 2–4 peers, every label stays short and untruncated at the minimum Settings width and in the longest shipped localization, and seeing all choices at once helps the decision. It is a menu (DropdownRow) when any of: five or more choices; dynamic or user-generated choices; long, explanatory, or localization-risk labels; or a constrained repeated surface where showing every choice would crowd or truncate. A binary — on/off, where off means the thing is absent — is a toggle, never two segments, drawn as a checkbox and never a switch (#2439, ToggleStyleConventionTests): a setting’s own toggle leads with its label beside it, and the override chrome’s OVERRIDE column is the one trailing checkbox; two named peer behaviours, neither an “off”, are a pick-one under this rule (App glyph style; Space history, #1655). Fixed editor-navigation tabs (the icon picker’s Emoji / Symbol / Glyph strip) may exceed four — they switch the visible editor rather than edit a value, and a future navigation strip past four segments still qualifies. Layout Defaults’ layout selector is a strip of live schematic thumbnails, not a segmented control (below). The same semantic field uses the same control on comparable full-width surfaces: the two bar cards both render Active indicator as segments. Segmented under the rule: the App Bar fields, Stack’s Master orientation / Stack position, and Corners. Menus where the rule keeps them: new-window placement (comparative labels), the seven-option Space layout mode, the dynamic Language and Desktop→Profile lists, and Wrap focus, whose value sits in the key recorder’s footprint on its Shortcuts row (#2670).

The update window’s notes tabs are one such strip (#1666): how many there are is set by the release feed rather than by us, so the strip falls back to a menu on the same selection wherever it does not fit.

The Highlights tab’s spotlight rows (#2038) are the one place a link is dropped rather than greyed: “Show me ›” trails a row only where this build can land on its setting, because a greyed link to a setting the build no longer has promises something nothing can keep. The row is one VoiceOver element, title and line, with “Show me” as its action. Following one, Settings carries a banner in the search notice’s surface with the paused banner’s buttons — Next, Back to What’s new, Dismiss — whose Next is absent on the last linked row, likewise not greyed; docs/design-decisions.md ▸ What’s new leads with a spotlight argues both.

Every shipped segmented strip fits a full-width row at the 720 pt minimum (SettingsWidthClass.minimum), measured against all ten locales (#95): the widest is Mouse resize action at ~503 pt (es, “Redimensionar las ventanas contiguas”), then App Bar active indicator at ~408 pt (fr). Re-measure a strip before adding a fourth segment to either of those two.

The per-Space override rows keep menus, not segmented controls (#291). The override editor is a full pushed pane (#678 8b) whose rows sit in a bounded ~700 pt column with a trailing OVERRIDE checkbox column (overrideStateColumn, 88 pt) and the OverrideChrome accent bar, so a 2–4-peer field has no room for a segmented render; OverridePickerRow renders .menu and has no .segmented branch, since every override surface sits in a bounded column. Restore a required-style parameter only if a genuinely full-width override surface arrives. The inherited (unchecked) state collapses to a quiet “follows <Layout> defaults · <value>” readout (the slot-size pair keeps its live control instead, dimmed via the chrome’s .disabled + .opacity(0.5)). App Bar Content (“Icon & name” → German “Symbol & Name”) is the one segmented label tight at minimum width; it stays segmented by width headroom and is re-checked for truncation when each new locale ships (#95), as the help-glyph labels are.

A KiwiVision lens’s rows (#2103) are such a surface too: an unchecked row reads “follows Default · <value>”.

Numeric controls pick one of three idioms by a single test: would a user say a specific number out loud? — never by which pane the setting lives in:

  • StepperRow (typeable field + arrows) for discrete, exact values a user names: counts, ms durations, pt thresholds — master count, columns/rows, track limit, animation duration.
  • Slider + readout (no typing) for a continuous feel or proportion tuned by eye: split ratios, master ratio, gaps.
  • Segmented / toggle for a non-numeric choice.

Minimum window size takes the same idiom on each axis (#204, #2237): a precise pt threshold, not a feel knob, drawn as Width × Height on one line, each a typeable field with its own stepper (100–800 pt, 10 pt steps). An “All layouts” master sits over a “Per layout” drawer, one row per layout. The master takes the gap masters’ acknowledging shape over an axis that reads “Mixed” (Interaction states), and the drawer stays open while any axis is mixed.

KiwiVision’s Dim is a ruled exception: a proportion, yet a StepperRow (0–100 %, 5 % steps), since with the mode on the desk behind Settings shows the effect as it steps (#2103, design decisions).

A stored SHARE keeps its slider and gains an exact chooser beneath it where the share has a second reading a user names (#1382): the scrolling slot’s Percent slider carries a Windows on screen stepper (the share is a count), and a split ratio’s slider carries fraction chips ¼ ⅓ ½ ⅔ ¾ (a share is a share). Both write the one stored value; the slider stays for feel. The stepper sits on the slider’s control axis rather than trailing, so the count reads as the slider’s own second reading; its ? states the size rule alone, and what the anchor makes of the count is the preview caption’s. The argument is in design decisions.

Layout Defaults picks one layout at a time, and picks it by its picture (#204, #678 turn 10). The layout modes are a fixed, small, mutually-exclusive set (LayoutMode minus Floating), so one mode’s editor is visible at a time instead of every mode stacked in one ScrollView. What selects it is a strip of live schematic thumbnails, not a segmented strip of words: the layout names are ones only a tiler user knows, so the drawing is the label — each tile also carrying the count of spaces using it. The strip lands on the profile’s most-used layout. The minimum window size is pinned above the strip because its master and drawer set every layout’s at once (and it decides when windows stop fitting). The rows come from the settings census and one LayoutCard renders whichever layout is selected.

A section or disclosure title is sentence case — “Applies immediately”, “Drag & drop”, “Move windows”, not “Applies Immediately” or “Drag & Drop” (R5, #406), which is what macOS System Settings uses for its own in-pane headers. Three boundaries, because System Settings itself draws them:

  • The rule is scoped to headers — SettingsSection titles and the labels of an “Advanced” disclosure. The SettingsDestination titles (Home cards, back-chip headings) stay Title Case (“Layout Defaults”, “App Rules”): they name a destination, as System Settings names its panes. So do action labels (“Open New”, “Set Gap Values”) — a verb-phrase command follows the menu-item convention, not this one.
  • An action may be a bare noun where the verb would collide, and “Layouts” on a preset card is the one that exists (#859): its verb-phrase form is “Show layouts”, and show is spoken for in this window by the detail panel’s own chip (“Show preview”) (docs/localization-naming.md ▸ Family C, rule 1). The clash is of verbs, not nouns: fr and ru both take the verb-phrase form, because in those catalogs the bare noun would be byte-identical to the Layout Defaults destination and DestinationNameCollisionTests would redden. Where both forms are free, take the verb.
  • & does not start a new sentence: “Size & float”, “Drag & drop”. The word after the ampersand is the one a sweep misses.
  • Capitalize only what a sentence would. A proper noun stays (“Lua bindings”); an acronym stays an acronym (“BSP”). KiwiDesk’s own named surfaces keep their caps — the App Bar and the Space Bar are things (“Space Bar colors”) — while a feature that is just its noun does not (“Focus border”).

“Advanced” prefixes a disclosure’s noun only when a basic tier of that same noun is visible above it; otherwise the drawer is named for what it holds (R3, #406). So “Lua bindings” and “Screen fingerprints”: the catalog above Shortcuts’ drawer is a different noun (actions), and Screens has no lesser fingerprint anywhere.

The qualifier must also be unclaimed by the surface AROUND the drawer (#678). A word a page already spends on its own section (“Advanced”, General’s) cannot also name which rows a drawer hides. The colours’ own section is “Colors & glass” (#2389), named for what it holds, after the page it replaced was called Advanced Colors, and its one drawer is Colors (#2593).

A connector (Advanced: Lua bindings) is not the fix: it restores the tier reading but re-splits the format. Dropping the qualifier loses nothing — collapsed by default, boxed, at the bottom of the page, and Shortcuts’ own caption (“the power-user escape hatch”) already say it. The bare “Advanced” (General) is untouched by the rule: no noun, nothing to misparse, and it is the only advanced thing on its page.

An inline disclosure row leads with a hairline rule, and its drawer opens into one sunken well. The thin top rule sets an accordion off from the plain rows around it without promoting the drawer to a card, and whatever the drawer reveals sits in a single well — never one well per child. SettingsDisclosure draws both; the ruling is in docs/design-decisions.md.

A drawer header is the whole row: one button, a resting cue, and its state in words. Every accordion header — both chromes, and the one drawer built outside the wrapper — takes SettingsDisclosureStyle, which makes the row a full-width .plain Button, rests it on a chevron that inherits the header’s size outright (bold, no .font and no scale step, so it moves when the header does and never outgrows it), and marks the row .isHeader for the headings rotor.

The TITLE’s tier is the wrapper’s, not the style’s. A drawer that goes through SettingsDisclosure draws its title at SettingsDrawerHeader.tier — .callout at semibold, carried on weight rather than size; a header drawn smaller and lighter than the rows it heads is what #1021 was. The one drawer built outside the wrapper takes the style (the button, the chevron and the heading trait) but keeps a deliberately quiet title of its own, so the tier claim is about wrapper users, which is the scope SettingsDisclosureSizeTests reads. The summary beside the title takes the same tier without the weight.

The chevron rotates on expand. There is no resting fill — hover confirms at rowHoverHighlight’s full-row ladder, never the icon chip’s, whose rest state is achromatic at row width — and the row carries expanded / collapsed on .accessibilityValue, because the button replaced the triangle that announced it. Click anywhere on the row, Space on the focused one; the cursor stays an arrow (the hand is link-only). A drawer’s accessory: may hold a control, so it is drawn BESIDE that button, at the row’s trailing edge — the row’s hit shape stops where the accessory begins.

What the drawer hides is stated on the row while it is shut, and it is not an accessory. A drawer takes summary: — words only — and the style owns the tier (.callout at ink3: description, where the chevron’s ink2 is the row’s affordance) and the shut-only rule, so neither is a call-site choice. It renders INSIDE the header button, unlike the accessory beside it, so the hover highlight covers it and clicking the words opens the drawer. It stays beside the title rather than under it because it states a VALUE and disappears on expand, where a caption explains what a thing IS and stays. The ruling is in docs/design-decisions.md.

A collapsible section may also take a preview: drawn under its row while shut — the Mouse & trackpad card’s picture strip is the one instance, ruled in docs/design-decisions.md ▸ Shortcuts.

A section header’s trailing readout is words only. A SettingsSection takes trailing: — a short value at the title’s far edge, drawn in ink2 at .subheadline, the Mac Checklist’s “Done: 2 of 4” — and never a control: an accessory that acts needs a name and a value of its own, which is the drawer accessory’s job above.

A collapsible container that stands beside the page’s sections is a collapsible section, not a drawer. SettingsCollapsibleSection IS a SettingsSection — the same plain .headline title, a heading, and plate — and the disclosure is the plate’s first row: a chevron beside the summary (.callout, ink3), the whole row one button, the entries opening below it inside the card. The row is not a heading (isHeading: false), so the card lists once in the rotor. A drawer that qualifies a card stays a SettingsDisclosure at the drawer tier. Both draw the one SettingsDisclosureButton, so the full-row button, chevron, hover and expanded value above hold for both, and search opens either on a hit for one of its catalog children. The ruling is in docs/design-decisions.md.

Weigh every title edit against the search index. Search indexes destination titles, every census-labelled setting row (SettingsSearchIndex, one row per SettingKey) and the catalog’s own controls — drawer titles, mode tabs — plus a sparse English synonym table (SettingsSearchSynonyms, match-only, never displayed). A title that drops a word no other indexed string carries makes its own drawer unfindable by that word. “Screen fingerprints” is the one indexed string carrying “fingerprint”, which is part of why that title wins. “Lua” is also carried by the init.lua rows in General and Shortcuts, but results are per row, so the raw-Lua drawer still needs its own title’s words. Per-instance rows (a space’s own binding row) stay out by design: their FAMILY is the setting, and the Made by you group is how a named thing is found.

Localization splits here, and the split is the rule from §5: R5’s capitalization and R3’s connector are cosmetic, so German keeps its own typography (“Erweitert: …”). Dropping “Advanced” changed the English meaning, so those two keys take scripts/drop-key and re-queue for translation — see docs/translating.md.

A section title labels its rows visually; it does not label them to VoiceOver. A diagnostic readout row (Screens’ fingerprint hashes) needs no visible per-row label when the drawer above it is named for exactly that value. But the title is spoken once while rows are stepped one at a time, so give such a row a combined accessibilityElement with an explicit accessibilityLabel naming the value, and keep textSelection scoped to the value itself, so copying for a support ticket yields the value alone.

Two things decide how far the combined element reaches:

  • Does the title name the value at all? Screens’ drawer is titled “Screen fingerprints”, so its rows speak the screen and the hash. The orphan-pin rows sit under “Pinned to disconnected screens”, which never says fingerprint, so their label carries both halves — the row’s own sentence plus the screen it is waiting for.
  • Does the row hold a control? Combine the readout only, never the whole row: children: .combine folds interactive children in too, so wrapping a row that ends in a clear or edit button costs that button its own element — scope the combined element to the static run and leave the control a sibling.

Option tabs are a solid sliding-pill segment control. Every pick-one-of-few chooser (layout parameters, mouse resize, icon picker tabs) uses SegmentedPicker instead of the native segmented picker: a capsule track where the selection is a solid accent pill carrying the on-accent ink — the accent-marks-control-fills convention — with no shadow (a white pill needs the thumb’s shadow to lift off a same-luminance track, which dies in dark mode). The selected state never rides colour alone: the selected label is larger and semibold, a real font-size step, because scaleEffect rasterizes the text and reads as blur. Liquid Glass is not used on the pill: bare glass over the flat settings background reads as washed-out, tint reads as “blue, not glass”, and the glass layer blurs content near it. ONE persistent pill slides between segments via matched geometry, adopting the selected segment’s anchored frame — styling attached to the selected label crossfades on selection change instead, because the view is destroyed and recreated. Segments are equal-width across the track (full-bleed, like a native window-toolbar switcher), a deliberate trade against content-sized segments. One control, one look.

Sliders follow the system slider. Every value adjuster (ratios, gaps, sizes) is a SettingsSlider: a thin capsule track, a full-strength accent fill up to the knob’s centre — a translucent fill reads as disabled — and a solid white capsule knob wider than the track is tall. While dragged the knob grows, follows the pointer rather than hopping between steps — settling onto the step on release — and turns clear Liquid Glass, the fill visibly running under it; that glass is Settings’ own control finish, so the overlays’ Liquid Glass switch does not reach it, and Reduce transparency keeps the white knob (#1527). A glass knob at rest was tried and dropped: it read as a blue knob. Accessibility is delegated to a native Slider representation, so assistive tech sees exactly the control it replaces. A signed value fills from its origin: the accent runs from the origin to the knob in either direction, and a notch marks the origin. A signed value that means darker or lighter (the sheen) also carries a moon and a sun at the track’s ends and a faint dark-to-light ramp on the track, and at Off the notch shows just above and below the knob, which covers it otherwise. A position along a screen edge (the Pocket’s) carries arrows to its ends, left and right on a top or bottom edge, up and down on a side, and speaks the end it counts from. The glyphs are decorative; the spoken value says which way.

Buttons take a native style, and semantic role chooses the class. No gradients or shadows on buttons — the crisp shadow is the slider thumb’s alone. The single custom style is the accent fill below, whose edge is a darker shade of its own fill rather than a border in a new colour. Class is otherwise expressed through native style + control size: .borderedProminent regular for a surface commit on a mode-varying ground (a popover confirm, an editor’s apply); .bordered large for row actions (Load, Apply, Set Gap Values), level with large dropdowns; .bordered regular for stateful input triggers (the shortcut recorder); and .borderless regular for icon-only row actions (trash, ×-clear, rename, a Space’s Customize). List-add actions stay .bordered; .plain + underline is reserved for inline prose links. Small controls are subordinate inline or popover utilities (Shortcuts import, override resets), never a normal row action. Native macOS shape differences between these classes are intentional — choose by semantic role, not by a desired silhouette.

The exception is a button filled with the app’s own accent, and it is about ink rather than silhouette. .borderedProminent picks its own label colour, white on macOS, which on KiwiDesk’s kiwi green measures 2.41:1 in both appearances (the accent is the same hue in each), under every legibility floor this app holds. accentInk is the ink the theme declares for text drawn on the accent, a pairing the theme’s contrast lens measures. Fill and ink are sealed into one style — KiwiProminentButtonStyle, applied as kiwiProminentButton() — which the first-launch tour’s primary action wears on every screen and the save pill’s Save wears for the reason below.

Reach for it only where a button carries an accent fill; a custom style is a cost, not a free recolour. A ButtonStyle keeps the Button, so focus, keyboard activation, VoiceOver and isEnabled survive; what it takes over is what the system style drew, so the pressed and disabled appearances are redrawn through the fill rather than the label, and a click never dims the ink.

Each of the three states is its own opaque token — accent, accentPressed, accentDisabled — with the ink held at accentInk throughout, since a state drawn as an opacity composites differently against every ground. Disabled drains the hue rather than dimming it: it measures 3.74:1 for the label, stays a visible control on every ground the seal is drawn on, and clears the protanopia separation floor — AccentStateSeparationTests measures that against ColorVision.separationFloor rather than a number written here.

On a ground that does not move with the appearance, the seal stops being a preference. The save pill is dark in both modes, and an inactive window makes AppKit re-pick a prominent button’s ink against the window’s appearance rather than the plate the button sits on: in light mode the label and the fill both collapse into the plate, 1.23:1 enabled and 1.09:1 disabled, against the seal’s 6.67:1 in every appearance and activation state (in dark mode the system button reads at 11.48:1). So the pill’s Save wears the seal, and every other control on that plate sets its own ink (#1198). Settings’ remaining prominent buttons take the system style; moving one onto the seal is its own change and its own eye-confirm.

A recording shortcut field wears an accent halo. The armed recorder among dozens of identical rows gets an accent fill + ring extending slightly past the button — the same accent-layer vocabulary as OverrideChrome’s active rows — because a tinted border plus a label swap alone is too quiet to spot at list speed.

Status badges stay flat. The thumb’s shadow is the settings’ vocabulary for “interactive, movable”; on a passive BadgeChip it would promise interaction the chip does not have. Depth comes from the hairline stroke both chip types share, matching the flat capsule language of native tags. One mark sits outside both vocabularies: the Screens picture’s main-screen card wears a soft accent bloom — decoration stating a fact, promising neither interaction nor an armed input, and always beside the textual “main” badge that carries the answer. A non-interactive value state in a control row (the slot size’s “Default — orientation standard”) renders in the same capsule language rather than as bare gray prose.

Jump chips are the capsule language as buttons (#1520). A long page whose groups the reader needs a map of — Shortcuts & Gestures is the one — pins a row of chips over its scroll, one per group, in page order, each labelled by its group’s own title. A chip is content-sized and wears its own edge over the chip rest fill — never the passive capsule’s hairline — lifting on hover and deepening while pressed, at the large-control height with the label at the size of the header it jumps to, with no count and never a truncation: the row wraps instead of scrolling, every line starting at the leading inset. The chip for the group under the bar is marked with a soft accent fill and a semibold label, the ink staying neutral (the accent marks fills, never text); the rest fill, its lift and its press sit on a layer of their own beneath that, so pointing at or pressing the marked chip never unmarks it, and the edge stays the same neutral line in every state. Like every button it takes no shadow. A chip is a button, the marked one announced as selected, and the row is one container named and captioned “Jump to”, the caption on a line of its own above the chips. A click puts the group’s header under the bar, and arrives without travelling under Reduce Motion. A chip whose card is collapsible opens it too. A thin rule may separate chips whose scope differs; it rides the chip before it, so a wrap never starts a line with it. The bar sits on the page ground and grows its lower hairline only once content slides under it. The caption line carries a readout at its trailing end, wrapping under the caption where both do not fit; the line is header chrome, the first thing a narrowing window drops — a name inside the readout is shortened before the sentence ever is, and the bare chips remain. Chips jump within one page; tabs, which show one view at a time, stay the update window’s control.

An “Automatic” color well shows adaptivity as a shape, not an absence (#429). Almost every color setting stores a concrete hex, but a few default to an adaptive system color (the sticky/floating marks use the label color, which flips black/white with appearance and has no fixed hex). A HexColorField opts into this with an automatic flag: an empty hex is then a valid value meaning “Automatic,” and the swatch draws a diagonal light/dark split (the macOS “Auto appearance” idiom) with an “Automatic” placeholder in the hex field. Clearing back to Automatic goes through the checked “Automatic” menu item — offered on every row-menu route (see “Interaction states” ▸ a row’s menu is one menu) — or by emptying the hex field and committing. Resolve empty through the mark fallback, never the generic hex parser, which falls back to the accent color. The flag stays off for the ~14 wells whose color has a concrete default and no adaptive concept.

A well whose Automatic is ANOTHER colour (#1856, the front-app indicator following “Focused window”) takes the flag AND a follows: while empty it draws the followed colour flat, its tooltip names the followed row by that row’s own label, and the picker opens on that colour — the split shape would promise light/dark following it does not do.

A well whose value may hold no colour offers “No Color” on its row menu (#2569, the KiwiShelf Fill). A HexColorField opts in with clearable. The item writes the hex at zero opacity with its hue kept (KiwiShelf.cleared(_:)), so raising the opacity brings the same colour back, and it is checked while the well holds no colour. The swatch then draws the clear checkerboard, and VoiceOver adds “No Color” to the well’s name. Whether a value has a colour is the one KiwiShelf.hasColour, so empty, unparseable and zero-alpha values are one state, and whatever paints it goes through NSColor(kiwiFill:) — never the generic hex parser, whose fallback is the accent colour, the trap the Automatic paragraph above names. It sits on the same row-menu routes as Automatic. A well may carry both flags, and their items then share the menu. Emptying the hex field commits No Color too, and the field then shows “No Color” as its placeholder; on a well with both flags the empty field stays Automatic’s.

Row order within a section is fixed-tier, not usage-frequency. A field’s vertical position is decided by what kind of decision it represents, so the eye learns one shape across every editor; natural adjust-order only breaks ties within a tier. A contributor placing a new field first asks which tier, then where in it. The tiers, top to bottom:

  1. Preview / schematic — in an area without a detail panel, leads unconditionally, unless the section has one master on/off toggle whose own state the preview depicts (Focus border’s dimmed-when-off preview): then the toggle sits directly above the preview, as a gate above the gated. In a panel area the preview lives in the panel column instead, and this tier is empty.
  2. Defining / structural fields the schematic takes as params — counts, ratios, axis / arrangement, positions — ordered coarse-to-fine. A numeric-threshold gate needs no strict adjacency to what it greys (Stack’s Master count gates Master orientation, with the unconditional Master ratio between them): unconditional-before-conditional outranks adjacency, because the greyed state already signals the gating. Strict adjacency stays mandatory only for a boolean-toggle-controls-one-row pair.
  3. Standing placement policy — New-window placement, steady-state behaviour rather than static geometry, sits last among the schematic-tied fields.
  4. Secondary, occasional-use toggles with their own captions (auto-derivation, wrap-focus), each still gate-above-gated internally.
  5. Escape-hatch buttons / actions (“Fit layout gaps”) — always last, and on the card whose VALUES they write.

An escape hatch that transforms other staged settings exposes the transaction locally: label transient inputs as action parameters, preview the resulting values before activation, warn when structure will be flattened, and confirm that the draft changed while the save pill’s Save is still required. The Fit layout gaps group is the reference pattern; its action is opt-in and one-shot, never automatic border-to-gap coupling.

Dividers mark tier boundaries, so a new field’s tier decides which divider-bounded cluster it joins — never wedge a field mid-cluster to dodge adding a divider. All five layout editors place new-window placement last among their schematic-tied rows (#291).

Wide controls share one label axis; compact controls trail (#2439). Every labeled slider and segmented picker puts its label in the same fixed-width column (SettingsMetrics.labelColumn), so those controls start on one line across sections; slider readouts share one trailing column the same way. A menu, a stepper and an action row’s buttons instead trail at their natural width against the row’s right edge (SettingsRowShape(controlTrails: true); SettingsRowShapeTests ▸ compactControlsTrail, whose trailing register is the one copy of which rows trail), and every override row’s inheriting readout trails, whatever its live control, so the unchecked readouts form one column. The Windows on screen stepper is the one stepper on the axis, since it is its slider’s second reading (Choosing a control, above). Rows read the column from the environment (\.settingsLabelColumn), and OverrideChrome narrows it once (overrideLabelColumn, paying for its trailing OVERRIDE checkbox), so a shared row dropped into override chrome lands on the plain rows’ control axis by construction. A numeric stepper is an editable monospaced field plus arrows — a value embedded in the label string (“Columns: 3”) or a plain readout beside arrows reads as passive, so the value is a real TextField that commits and clamps on Return / focus loss, with an optional unit suffix (“ms”) between the field and the arrows. The color grid is the exception to the shared axis: its two-column HexColorField layout keeps its own label width (colorLabelColumn), because the shared axis would misalign the grid’s second column. Dropdowns go through DropdownRow and take .controlSize(.large) so a menu button’s height sits with the capsule tracks around it. Within a section, a Divider separates geometry controls from the behavior dropdowns (new-window placement).

An action row (SettingsActionRow) is a title, a caption and a ?, with short trailing buttons (“Export…”, “Reset…”). Each button is named for VoiceOver and anchored for search by its full action name from the destination’s catalog, so the short word never stands alone. Groups of action rows inside one card take a SettingsGroupHeading.

The readout column is sized by a word, not a number (R6/#406): an Auto-gated slider prints “Automatic” there. The readouts render in the proportional system font with monospacedDigit() — System Settings’ own idiom — which keeps the column at 72 pt rather than the 84 a monospaced face would need. The per-space override rows, the app’s narrowest editing surface, absorb it in their bounded column, whose label column (overrideLabelColumn) is narrowed to pay for the trailing OVERRIDE checkbox. Alignment stays trailing: the readout’s outer edge is also the pane’s right margin, so trailing is the only choice that pins it to one line down the whole pane. See docs/design-decisions.md before narrowing it again.

Preview alignment splits on standalone-vs-paired, not by tab. Which one is decided by whether controls sit right next to the preview:

  • Standalone illustration (a Layout schematic, a bar or palette scene) — centered in its card with a caption below. No control column shares its row, so it reads as a figure, the way System Settings centers a wallpaper thumbnail over its label.
  • Preview paired with the exact controls in the same card — left-aligned, flush with the control rows it drives, so preview and controls read as one stack (the accent-swatch / Displays-arrangement pattern).

A new preview picks its bucket by asking “are its controls right here beside it,” not by copying its page. Every area in SettingsDetailPanelOffer draws its preview in the panel column, where no control shares its row, so it lands in the standalone bucket by construction; the paired bucket stays for the next preview that does sit in a card beside the rows it drives.

The areas that watch their draft do it in a fixed detail panel; the rest keep full width. Which ones is SettingsDetailPanelOffer.offering and nothing else — Gaps & Borders, Bars, Looks & Animations, Layout Defaults, Shortcuts (its keyboard board), Spaces and KiwiVision (#793, #794, #2103). They open as two columns: the controls, then a fixed 392 pt right panel headed “Live preview · ” that redraws the area’s preview from the staged draft, with a “Changed in this draft” list of old → new rows underneath, each a jump to the control that changed. The offer is consulted by the two-column mount, the save pill’s centring offset and the guards alike — an area with nothing to show hides the panel and takes the full width, so absence is a stated verdict, never a missing branch. The panel mounts the area’s one renderer — a migrated card preview, or (Shortcuts) a panel-first drawing with no card twin — and that area’s cards carry no duplicate preview (DetailPanelTests holds the offer set and the removed in-card mounts; the ruling is in docs/design-decisions.md ▸ two columns).

KiwiVision is in that set too (#2103). Its Default rows write at once, so on that chip the panel draws the live values they wrote; a lens’s page is a draft saved with Save, so on a lens’s chip the panel draws the draft’s lens. Why is Design decisions ▸ the panel’s object is the DRAFT.

The panel keeps its column only above 1200 pt. Below that it waits behind a “Show preview” button, which opens it as a card floating over the content — draggable by its grab bar, closable, and always landing whole inside the window. An area that offers a preview always has exactly one way to it at every width, and the pill’s centring offset answers to the docked form alone. Opening and closing the card are per-mount, never a stored preference: navigating clears the answer, and above 1200 the panel takes its column back whatever the answer was.

A picture of something that is not the draft goes in a SHEET, not the panel (#859): the preset preview is a sheet off the card, and Profiles stays out of SettingsDetailPanelOffer.offering (DetailPanelTests pins the refusal). Why is Design decisions ▸ the panel’s object is the DRAFT.

Transient surfaces are chosen by what they hold, not by size. A popover is a glance or a small edit anchored to the control that opened it (a ?, a rename field, a chip overflow). An alert or confirmationDialog is a question with consequences. A sheet is a body of content too big for an anchored popover and not a question at all: one dismissal, and it writes nothing. A sheet that grows a commit button is a dialog wearing the wrong chrome; give the commit back to the surface that owns it.

Two mechanics a sheet owes. It is hosted where its identity is stable for as long as the area is, never inside a LazyVGrid’s cards or any other subtree its own presenter can tear down (the rule SettingsView follows for the one discard dialog), and it is presented by item: over an Identifiable request — the rule for any presentation whose content is built from one row, stated as that in .claude/rules/gui.md. Its one dismissal answers Return and Escape both: a Button carries only one shortcut, so .defaultAction rides the visible Done and Escape is a second, hidden button carrying .cancelAction (the preset preview and About both). SheetPresentationSeamTests is the register of which files may host a sheet, and holds all three.

Layout schematics draw staged values, never live windows (#125). Each layout has one GapsDiagram-family schematic (LayoutSchematicKit / LayoutSchematicCanvas hold the shared canvas, tile, and ghost language) that redraws from a settings value — the staged config as the user edits it, or the settings of whatever object the picture is of — never from live window state, no AX calls: a preview answers “what would this look like” without mutating the session, the #123 never-live-apply principle. No hover, no tap-to-inspect, no drag-to-preview, and no idle animation — a schematic eases between staged values, but nothing loops (a timer in every tile for a pane open seconds at a time is the cost that rules it out). Every tunable layout gets a schematic, Monocle included — it draws the navigation model (a fan of full-screen cards + orientation cycle chevrons), not geometry.

A thumbnail that is read rather than compared plays its layout once — the one exception to the no-idle-animation rule above. The setup tour’s Spaces step and the preset preview sheet host it: it plays one short story as it appears and again on hover, never loops, and under Reduce Motion never leaves its resting frame. That frame is not the schematic described below: a tiling story draws the engine’s arrangement of windows arriving one at a time, no story draws a + slot, and it rests on its story’s own window count — lowered to what the Space fits without a pile — rather than LayoutSchematic.defaultWindowCount. The Layouts chooser, its Live preview and the Home cards stay at rest. Why is design decisions ▸ A thumbnail that is read rather than compared.

One schematic serves several surfaces at two scales (SchematicScale, whose own doc comment is the authority on what each scale is for): a thumbnail in the “Choose a layout” strip and in the preset preview sheet (#859), and the full-width drawing in the Live preview card — the one that carries the caption, showsCaption answering true for panel alone. Inside Layout Defaults both mounts take a window count the reader drives from that card’s slider, so the drawing simulates the layout at a count rather than illustrating it at a baked-in one; a mount with no slider beside it passes LayoutSchematic.defaultWindowCount instead, a stand-in and not the reader’s own windows either way. Why the count is an input, and view state rather than a setting, is ruled in docs/design-decisions.md; that it does not render the reader’s real windows is in accepted limitations. Tile counts are capped for legibility with a “+N” chip.

“+N” means the same thing wherever it appears: there are N more, and here is how to see them. The schematics’ legibility cap above and a screen card too small to draw all its chips set the grammar; the Home cards’ overflow chips and space fan reuse it, and a new surface must not invent a different one. It counts the items NOT shown (never the total), it takes a slot of its own so it never claims to hide exactly one, and it is an affordance rather than a label wherever the hidden items have their own controls: on a screen card it opens a popover holding every chip, each working as it does on the card, because a chip that is merely counted has lost its clear button and its menu.

“+N” governs a bounded container that cannot scroll; content past the fold of a SCROLLING container is cued in words instead (#1292). KiwiHub’s Shortcuts side is the case (#2246): its sections are non-uniform (titles, a caption), the fold cuts through a row, and the sheet knows heights rather than rows, so any N would be an estimate wearing a precise shape. The side says “Scroll for more shortcuts” and counts nothing; it is read-only, so the hidden items have no controls to lose and a label is admissible.

The middle clause is arithmetic, so it is code: OverflowSplit.shown(of:fitting:withMarker:) is the one statement of it, and a surface that caps a run routes through it instead of restating the sum. Each caller still measures its own capacity — a screen card from its geometry, a Profiles row from a fixed slot count. Surfaces predating it still compute total - cap and so can render “+1”; adopting one is its own change, since routing it shows one item fewer.

One frame per layout, and the conditional facts ride a shared ghost vocabulary (#125, #753). Every schematic is a single frame — docs/design-decisions.md carries why the two-frame sequence retired — and each carries whatever is conditional about its layout with one of a small shared vocabulary: a spawn ghost (dashed accent tile + ”+”, “the next window lands here”: BSP’s incoming window, Track’s own-vs-focused track), an off-screen ghost (solid gray, straddling a drawn screen edge, “a real window scrolled off-screen”: Scrolling’s side panel), and the empty-cell gap (dashed gray, “unused grid space”: rigid Grid). A fact the reader can reach by dragging the window-count slider — a grid rebalancing as a fifth window opens — is expressible in that one frame; a fact about motion is the caption’s to state, in words, and a caption that carries one switches with the control that changes it rather than stating it under every setting. Where a ghost needs more room than a thumbnail has, the thumbnail leaves it undrawn rather than shrinking the frame around it — undrawn, not merely clipped (SchematicCanvas.screen says why). The app bar shown in Scrolling/Monocle is not drawn into their schematics (one preview, one job); its presence surfaces as live On/Off state in the CrossReferenceRow that points at the App Bar destination (#229).

Stack’s and Track’s overflow are drawn the way the engine places them (ZoneRun): the windows that do not fit sit behind the edge tile.

A GUI label may diverge from the Lua/JSON wire name when the label alone is ambiguous (#217). The Grid picker shows “Arrange: Columns first / Rows first”; the wire vocabulary stays split_direction: horizontal | vertical (horizontal = Columns first). “Split direction” collides with two opposed real-world conventions (divider-axis vs stack-axis); the row/column labels are unambiguous under both. Only the display label changes — churning the documented verb widens the blast radius (override commands, existing configs, testers’ mental model) for no gain. New option labels are new keys; what a relabel does to the label’s own key is docs/translating.md ▸ Maintaining the key set ▸ Rename vs. drop vs. deprecate’s.

Geometric wire, presentational label — the rule for every two-axis layout. The Track picker has the same collision (do the tracks run horizontally, or do windows stack horizontally?), so the GUI labels it “Arrange: Columns / Rows” (reusing Grid’s scroll_grid.arrange label; Track’s options are bare Columns/Rows, no fill-order “first” since Track has no growth semantic). The Lua/JSON wire stays geometric for both (grid.split_direction, track.axis = horizontal | vertical): a wire value describes orientation, unambiguous where nothing is visually parsed, and it keeps Grid, Track, and scrolling on one axis vocabulary. Renaming the wire to columns/rows is rejected on gain, not churn cost: Grid’s value carries fill-order (columns_first) and Track’s carries pure orientation (columns), so no single key/value shape unifies them — a rename would move the inconsistency to Grid-wire↔Track-wire and turn a geometric term into a presentational one (an “axis” whose value is columns). Single-axis layouts (Scrolling, Monocle) stay plain “Horizontal/Vertical”. Fix the label, never the wire — when the wire term is accurate; see the next entry for when it isn’t. §5’s one-vocabulary rule (Lua == JSON) holds either way and is orthogonal to this GUI↔wire question.

When the wire is the outlier, rename the wire instead (R6, #406). The rule above covers the case where the wire term is correct and the label alone is ambiguous, so only the label moves. When the wire term is factually wrong for what it names, the accurate side stays and the outlier moves. Two renames are that case: track.set_count → track.set_limit (the GUI says “Track limit”; the value is a cap that auto_tracks overrides, not a count of what exists), and tab_background → background_style on both bars (the entries are not browser tabs, and under Plain no item draws a box of its own in steady state). The discriminator is never churn cost — a Lua verb renames freely and a stored key owes its migration (§5) — but which side misdescribes the thing.

An override stepper’s range must exclude any value that carries a separate meaning on the wire (audit finding 20, #406). In a per-space override row nil is the inherit sentinel (OverrideChrome), so every value inside the stepper’s range is a stored value with one meaning. A per-space Track-limit stepper running 0...10 beside its global twin at 1...10 gives a stored 0 two meanings, decided by which surface wrote it: resolved to max(1, 0) = one track, while the Lua setter’s 0 means automatic and is never persisted (it flips auto_tracks instead). Override steppers are 1-based wherever 0 carries a separate Lua meaning; “automatic” belongs to its own field. Track has no per-space automatic row, so the grey there keys on the RESOLVED auto_tracks (#171) — the value the space actually gets, never the global.

“Automatic” is the word for a value; “Auto” is the adjective in a toggle label (R6/#406). A value the user reads or picks takes macOS’s own full word — the empty-hex colour sentinel, a screen chip’s placement, and PtSlider’s readout, which prints Automatic in place of 0 pt while the Auto sentinel is set. A toggle that turns automation on takes “Auto” as an adjective, since its noun is already on the next row. Two shapes, picked by how many fields the toggle gates:

  • One gated field → Auto <Field>, naming it exactly: “Auto font size” over Font size, “Auto track limit” over Track limit. The pair reads top-to-bottom.
  • A gate over a set of fields → verb + object naming the composite: “Auto-size grid” gates Column limit and Row limit, so no single field name exists — “Auto grid size” would name a field that appears on no row. Do not “fix” it into the first shape.

Either shape must name what is decided automatically: no track is itself automatic, only how many of them exist, so “Automatic tracks” is not a label. And “Automatic” is for a value the system computes — a fixed built-in default stays “Default”.

Neither word fits a field that can hold no value at all: that is an absence, and it is drawn as one. Where a field holds no value — because the setting then simply is not made — the cell draws an em dash, never a menu item named after the absence. The system decides is a value and takes “Automatic”; there is no rule here is not a value and takes no word, since a word for it has to agree with every sentence the surface uses for the same state and cannot. Ask which of the two you have before reaching for either. A table column is where this bites, having no way to render a row that omits the field; App Rules answers it with one list per rule, so no row has the absent cell (Design decisions ▸ App rules).

A boolean mode flag names the MODE on the wire, even when its label names the field it gates (R6/#406). “Auto track limit” ships against wire track.set_auto_tracks / layout.track.auto_tracks, a decided divergence: with the flag ON, limit is not computed automatically, it is ignored entirely (TrackParams.trackCap returns 0, meaning unlimited, and the count falls out of geometry). So the wire names a mode — “the partition manages itself; the limit field is dead” — while auto_limit would name an automatic limit that does not exist. The label may still say “Auto track limit” because a label is read beside the field it gates, and a wire name is read alone. This is the third clause of the rename discriminator: relabel when the label is ambiguous, rename the wire when the wire term is wrong, and expect a mode flag to diverge from its own label by design.

A row’s menu is one menu, on every route. Wherever a row offers a contextual menu (palette tiles, space rows, screen assignment chips, adaptive color wells), the same items are its right-click menu, its VoiceOver actions, and its keyboard route — a chord on the focused row, stated in the user guide’s keyboard section. One builder feeds all of them through a single seam, so the routes cannot disagree; a surface adding a row menu gets every route by taking the seam, and a row in the family must be focusable so the chord has a target. (#845; the seam and its guard are engineering-side, .claude/rules/gui.md ▸ the keyboard path.)

Every animation KiwiDesk’s own chrome plays honours Reduce Motion, the Settings window, the setup tour and both bars included. Their motion — a caption fading in, a list springing into its new order after a reorder, the scroll that carries you to a search hit, a layout preview or the gaps diagram re-flowing as you drag a slider, a hover or focus fade, the setup tour’s progress row, an App Bar’s items sliding as its run changes width, the Space Bar’s drop ring sweeping — stands down when macOS System Settings ▸ Accessibility ▸ Reduce Motion is on, by dropping the motion, never the affordance: the caption still appears and leaves, the rows still land in their new order, the scroll still arrives, the preview still redraws at the staged arrangement, the bar’s items still show the run that changed — they arrive rather than travel. This is separate from the Motion card in Settings, which governs how managed windows move; the system setting wins over both. (#989, #1069, #1078; the engineering obligations and their guards are .claude/rules/gui.md ▸ the Reduce Motion gate and .claude/rules/bars.md ▸ the bars start motion in one file.)

The bars’ own animations are not on that card: their lengths — Reveal duration and Expand duration — sit in KiwiShelf ▸ KiwiShelf ▸ Show ▸ Durations beside the Reveal picker, and the card points there. Animate KiwiShelf has no row; while the Lua-only animations.on_expand is off, Expand duration greys and names it. While Reduce Motion is on, the card also says so in a warning box at its top, with a button to System Settings ▸ Accessibility.

The Space switch is on that card: Space switches at its top picks Slide, Show the Space or None, and Slide duration beneath it paces the slide (#1956, #2524). The Animate windows master greys neither, and Reduce Motion greys only the duration: under Reduce Motion a Slide shows the Space instead, which is how a switch reads with less motion, and the plate drops only its fade. The duration also greys while the pick is not Slide. The pair closes with “Applies at once, in every profile.” and a divider above Animate windows, and the card’s Reduce Motion warning names window animations alone.

Three bar motions keep playing under Reduce Motion, by the owner’s ruling: a hidden shelf’s reveal — slide, grow, unfold or fade — its mark growing under the pointer, and the shelf’s expand (docs/design-decisions.md ▸ The bars honour Reduce Motion).

Nothing else is exempt, including the marks whose movement carries meaning. The setup tour’s waiting dot stops pulsing and stays put, so the sentence it belongs to is still marked, and the tour’s progress row fills the same pips without the crossfade. The Space Bar’s spring-load ring keeps the ring, which says a hold here will spring, and loses only the countdown sweep. What you see is in the User guide; the pricing is in Design decisions.

A drag source is legible at rest, not on hover. Paint cannot say “draggable”, and hover arrives only once the pointer is already there — so a token you can pick up wears a closed, full-perimeter edge at a real weight, which reads as a piece lying on the plate rather than ink printed on it. Two obligations follow: the edge is one weight for every kind (a kind moves its alpha, never its width — a sub-point stroke is a half-pixel at 1x and can vanish on an external screen), and nothing that is not a drag source wears that costume, since a rest cue only reads as one if its neighbours lack it — the Screens +n marker, which opens a popover, takes the shared chip rather than the pinned chip’s fill (#1240).

Picking from a picker IS the add. Where a control’s whole job is to choose a thing that then becomes a row, the choice commits it — there is no second button, because a confirm pressed once after a selection asks for a decision the picker already took. Both the app rules row and the app shortcuts row work this way. The typed free-text path keeps a commit of its own, because every keystroke of an identifier is a prefix of that identifier and no moment in it means “this is the one”.

Where the list may hold no empty rows, the picker names the rule it composes. App Rules keeps one list per rule, each with its own picker — Open an app in a Space… and Float an app… — so a pick lands a complete rule. One picker over a defaulted rule authors a choice the user did not make (Design decisions ▸ App rules).

These pickers exclude the entries that cannot be added, but their escape route — one file panel, the same on both rows (#1279) — bypasses the exclusion, so a pick can still arrive that creates nothing. Where a pick can be refused, the refusal speaks as a caption at the picker that refused it, derived from live state so it clears itself the moment the user frees what was taken, and announced once for VoiceOver, since the refusal can land as a panel dismisses. It is not a dimmed control: with the button gone there is nothing left to dim, and a dim was never a sentence. It is keyed to the picker rather than to the section, or a list of rows prints one sentence under every one of them.

One escape, and it browses. Both rows offer Other… and neither takes a typed identifier: a person who needs to name an app that is not installed writes Lua, which is where powerful on demand lives, and a GUI control for that case costs every other user a second thing to understand (#1279). The list itself reaches one folder deep, so browsing is the rare fallback.

The Tile picker’s list is the one exception: it holds only the apps a guess floated (#2071, design-decisions ▸ App rules), with no search field over its short list.

The Space name field is the same channel for a typed edit (#1623): while the draft names another Space or is empty, a caption with a warning triangle under the row says why the edit will not stick, and a refused name’s revert is announced. The add row takes the same caption for a taken name (#1531) and never needs a name at all: + with the field empty makes the next number. The app rules row still drops a duplicate silently, and owes the same channel.

Hover confirms custom hit areas; it never creates the only affordance. A bordered action button (settingsActionButton(), KiwiHub’s footer buttons) is a button chip: chipRest, chipHover under the pointer, chipPressed while pressed, under a constant chipEdge, no animation, a disabled one not reacting, in the system bezel’s rounded rectangle (#2377). A palette or look tile lifts with chipRest as a fill apart from its applied stroke, and nothing a click would leave alone lifts. A prominent (accent-filled) button, a menu picker, a stepper, a checkbox, a toggle, a slider, a field and a sidebar keep system behaviour: their shape already says “control”. Ambiguous icon-only borderless actions use the shared adaptive chip on hover only, resting as the bare glyph in ink2 at a row’s end (iconHoverChip) — the accent is a control fill’s, not a glyph’s — while a glyph standing alone beside text keeps the chip’s rest fill (resting: true), nothing else marking it as a button, and the ? rests bare with no inset, its own circle being its shape; a chip-shaped control keeps its rest fill, which is its shape; custom full-row picker entries rest at nothing and take the chip’s rest fill on hover alone; an unselected custom segment lifts by one rest step over its track, and a choice chip — a layer, a preset — rests and lifts as a button chip, the chosen one washed with the accent instead. A draggable token chip is the one case stated as an ORDER rather than a step, because its kinds are drawn apart by outline-versus-fill and hover must not spend that channel: the outlined kind’s hover fill stays below the filled kind’s rest fill, and the edge carries the rest of the lift — which is also the only channel the outlined kind has, having no fill to raise. No scale, movement, shadow, or pointing-hand cursor on ordinary buttons (the hand remains link-only). Disabled controls never react; under Reduce Motion the color change is immediate. Every such control also needs an explicit accessibility label (and concise hint when the action is not obvious), a visible keyboard-focus state, and a recognizable rest treatment or list context — .help() and hover alone do not make a control discoverable.

On KiwiHub’s tinted Fill a button chip’s fills fade with the panel’s other fills (#2725).

A Layouts strip tile lifts like a palette tile, with the selection drawn as its stroke alone. A borderless value-menu or popover trigger whose label is text takes the glyph chip’s lift without its ink2 tint (menuTriggerHover), chosen by role since a value’s label must not go ink2, and drawn on the control because AppKit draws a borderless Menu’s label; the menu itself stays native. A button that opens a pop-up of its own — the Font and app pickers’ triggers and the profile chip — draws as the native pop-up beside it: a capsule on the chip ladder with no edge, still lifting under the pointer (settingsPopUpTrigger()). A searchable dropdown is the exception to custom full-row picker entries: its pointer and keys share one cursor, moved by real pointer motion only, and the font family picker, the app picker and the header search draw it as a solid accent fill with accentInk text, their rows taking no hover lift beside it (DropdownCursor).

Every chip fill in that ladder is a theme token, never a bare opacity. A chip rests at chipRest, lifts to chipHover and, where its style can see the press, deepens to chipPressed; a full-row entry’s hover and a segment’s lift are one chipRest step; a segmented control’s track is trackFill, fill and rim. All are stronger in dark, where a 6 % lift on the near-black page does not read as a shape (#1520, #2047). A text chip that is a button also takes chipEdge, constant across states — the jump chips, a screen card’s +n, the collapsed screen-setups chip, a colour well’s swatch, a layer or preset choice chip: the hairline capsule is the passive chip’s vocabulary, so a button chip is told apart by its edge before it is ever pointed at. A glyph-only chip and a segmented track take no edge — no passive control shares their shape.

Inapplicable controls are greyed, not hidden. When a setting makes another control inert — Auto-size grid overrides the Column limit / Row limit steppers (#171), Auto track limit overrides the Track limit stepper (#178), Fill empty cells does nothing in a rigid grid, the scroll-duration row is dead when Animate focus shifts is off, the bars’ Content picker is inert on a vertical edge and their Background color inert under Plain — the dependent control stays visible and .disabled, never removed. Hiding it would jump the list layout every time the governing toggle flips and lose the cue that its stored value is preserved (turn Auto-size back off and the old counts return). Greying reads as “not right now”; hiding reads as “gone”. Precedent: the scroll-duration row, gated by onScrolling (LayoutDefaultsGates).

One deliberate exception: the floating save pill vanishes at zero. Save and Revert live in a dark pill floating over the content column — the unsaved count with the edit target’s name (“3 unsaved changes to Desk”), then Revert, Save a copy… and Save — shown only while there is something to act on, and offset left of a panel holding its own column. The count line is the draft list’s opener: while the draft has attributed rows, clicking it pops the old → new change list, each row a jump to the control that changed — the one such count in the window, the header carrying none. Once the draft is clean the pill disappears rather than greys: it is the narration of a draft, not an inapplicable control with a stored value to preserve. GreyOutHidingTests carries the exemption; the ruling is in docs/design-decisions.md ▸ the floating pill. Below 900 pt the same three verbs and the same count dock into a full-width bar at the foot of the window — one view, two containers, because at that width the floating form covers the rows it is about. No draft, no bar.

The pill’s list also carries a Live, not saved section: each stored setting changed live outside Settings, narrated saved → live and counted with the rest. While one stands the pill and its bar stay, a clean draft included, and Save keeps it; the detail panel’s “Changed in this draft” list leaves these rows to the pill.

Rows go two-line below 900 pt. A labelled row’s control normally hangs off the shared 210 pt label axis or trails at the row’s edge; below the row breakpoint the label moves above it and the control starts at the row’s leading edge with the whole width in front of it, every row in the window at once, so the alignment that makes a section scannable is traded whole rather than per-row. Nothing is removed and nothing shrinks. Home’s card grid steps 4 · 3 · 2 on the same thresholds, and below 820 the header’s search field collapses to its glyph, opening in place when clicked or with ⌘K.

A revealed target gets a transient wash, never a ring. When search (or any cross-reference) sends the user to a specific place in a pane, the pane scrolls that place’s card to the top — a control landing without the heading that names it reads as disembodied — and its heading takes a brief accent wash: SettingsTheme.accent at 0.18, a corner-6 rounded rect bleeding 4 pt past the content, flat for 300 ms, then eased out over 900 ms (SettingsReveal owns the numbers). Reduce Motion drops the cross-fade only: the wash shows for the same ≈1.2 s and then disappears, because a flat tint shown and removed is not motion.

An inline drawer has no card of its own — it lives below its section’s heading, inside that section’s card — so its scroll target is its enclosing section, hoisted to the section’s top: the heading that names it stays on screen and the wash lands on the drawer’s own label below it (#610). A control anchored at its own render site (.searchAnchored) is the third shape: the row is both the scroll unit and the wash, so it lands at the top edge without the heading above it — the gap edges and General ▸ Files & Reset’s rows land this way. Where such a row sits behind a SettingsDisclosure, the drawer opens first — it expands only for its own catalog children — and the result’s breadcrumb names the drawer (#277, #1250).

Three things this must not become. Not a ring or halo — that is this app’s vocabulary for “this input is armed” (RecorderButtonChrome, the search field’s focus stroke), and spending it here promises a keyboard focus state with no FocusState behind it. Not a pulse or scale — the same bouncy motion the layout schematics reject. Not nested inside a GreyOut — GreyOut multiplies opacity on its content, so a wash under a dimmed block compounds to ~0.09 and reads as a rendering fault; a hit on a control some other switch has greyed still deserves full strength, which is the point of “grey, don’t hide”.

Sequencing matters more than the paint. Select the destination, then the local surface that renders the target (a layout’s card, SettingsSurface.layoutMode), then yield one layout pass before asking the scroll proxy for the id — a scrollTo in the same synchronous pass as the state change that mints the view will miss it.

An action that reloads must ask before it discards. Any Settings action whose tail is model.reload() re-seeds from disk and clears isDirty, so it destroys whatever the user has staged. Route it through SettingsModel.discardingEdits, which runs it immediately when nothing is staged and otherwise parks it behind the one dashboard-wide dialog. Supply the specific consequence as the message (“Loading a profile replaces the edits you haven’t saved”) and put the verb on the confirm button, so Cancel is always the safe choice; the title and Cancel are shared and must not be re-stated per site.

The parked action has to genuinely discard — flipping a flag without reloading leaves the save pill up, still claiming unsaved changes, after the user agreed to lose them, and prompts a second time on the way back. Return early on a no-op before calling the gate: it cannot tell an inconsequential action from a destructive one and will prompt for nothing. Do not solve this by hiding or disabling the action while dirty — prompting keeps it visible, which is what “grey, don’t hide” asks for. A source-scanning guard (DiscardGateParityTests) fails the build on an ungated path; see docs/design-decisions.md for why the gate sits at the call site and which exceptions are deliberate.

A profile delete asks every time, not only while dirty. Route it through SettingsModel.confirmingProfileDelete, never discardingEdits: it parks behind the same one dialog whether or not anything is staged, and when edits are staged its message says they go too, never a second dialog after the first. The dialog’s PendingDiscard.Kind decides its title (naming the profile), its Cancel wording and that Return picks Cancel, so a reflex keypress deletes nothing. DiscardGateParityTests ▸ profileDeleteTakesTheAlwaysGate fails the build on a delete that takes the plain gate. Why is Design decisions ▸ every edit-dropping action routes through one discard gate.

Leaving the window takes the gate’s third verb. A close or a quit over unsaved edits goes through SettingsModel.leavingDraft, the one place Save / Discard / Cancel is offered; an action never takes it. DraftLeaveTests holds its verdicts and QuitSheetsWiringTests the close and quit paths that reach it.

Greying applied across a whole editor (#520, #527):

  • Read the claim aloud before writing the gate. Greying row A off setting B says turn B on and I act; where A keeps working without B, the intent was a FLOOR — “don’t leave yourself with none of these” — and a greyed control is the wrong vocabulary for a floor. Sticky’s on-window mark is ungated off the Space Bar for that reason (StickyMarkUngatedTests; the ruling is in docs/design-decisions.md ▸ Overrides & appearance). The same reading applies to a census gate:, which records the dependency as data for whatever renders it.
  • Gate a whole editor off its own switch, not just the odd row. When a switch turns off the thing an entire section configures — the Space Bar’s own toggle, “no layout shows an App Bar”, a drag visual’s Enabled — dim the whole block. FocusBorderEditor is the reference shape.
  • Ask the value that is actually read, never the global. A gate keyed on a global while a per-layout or per-space override is what renders greys the only editor for a value in use — the worse failure, because the control is live and looks dead. Resolve first (bar.resolved(with: global), resolvedGrid(for: space)), and ask whether any consumer still reads the field.
  • Conjoin an inner gate with its block gate. GreyOut multiplies opacity, so a row dimmed by both its own rule and the block above it lands at 0.25 and reads as broken rather than disabled. Write the inner predicate as blockIsOn && ownRule.
  • Never gate a DisclosureGroup — gate its content. A disabled disclosure refuses to toggle in either direction (#527): shut, the values inside are as hidden as if the rows had been removed; open, the user is stranded in a wall of dimmed controls. Put the GreyOut on the drawer’s content and leave the label live. A block gate therefore must not wrap a section that contains a disclosure: push it down to the siblings, or pass the gate into the child view (ColorsAndGlassRows(allows:gateHelp:), which the Advanced Colours cards hand the shelf’s block gate row by row). Expansion state is preserved across the toggle — with the label live, closing is one click.
  • A block gate’s explanation lives on a live anchor, not inside the block. SwiftUI’s .disabled is cumulative and .disabled(false) is a no-op, so every HelpButton inside a greyed block is dead (#527). A block gate (a whole editor or multi-row group) therefore renders a live ? outside the gated subtree, passed only while the gate is active and carrying the why-off and how-to-enable copy, anchored on the nearest live label that scopes exactly the gated content — the SettingsSection header (its help: parameter) when the whole section body is gated, the drawer’s live disclosure label when only the drawer’s content is. A header ? may scope a card whose census-exempt rows stay live exactly when its copy points at them. When the gate must reach inside a child view to do this, pass it in (ColorsAndGlassRows(allows:gateHelp:)) rather than wrapping the child from outside, which would disable the anchor too. A control-scoped gate — one row, its gating control directly adjacent (Background style over Background size, a toggle over its slider) — keeps just the GreyOut hover string: the adjacency answers “why”. Adjacent is derived (#815): same area, same container, and the gating row drawn whenever the gated one is — two rows inside one disclosure qualify, a row on the same page but in another card does not, and that last case draws its reason inline instead (GateReasonPlacement, argued in docs/design-decisions.md ▸ usable without a mouse is a second claim). A master control higher up the same page, driving rows in later cards, is not a fourth case: where a shared control would need dimmed per-instance twins to explain itself, delete the twins instead (Design decisions, #754). What a master owes once the twins are gone is an acknowledgement: while the values it is about to overwrite disagree it carries a ? saying so (“The edges are set differently right now; a value here sets all of them”) and stays live, and a segmented picker whose pill can hide for an unmatched value shows no answer rather than asserting one. A remote control-scoped gate (the gating field lives on another destination) has no adjacency to answer “why”, so it takes a live pointer whose sentence names the destination to go to, in one of two shapes. Where the block has a live label, the pointer is a ? on the nearest live label above the dimmed rows: in Colors & glass every colour gate is remote, so the KiwiShelf colors card carries a header ? for its block gate (ColorsAndGlassHelp), while a row greyed in a live card takes a CrossReferenceRow beneath it (#1310) — in the role groups, one per page whose switch dims a row (#1857). Where it does not, the pointer is a CrossReferenceRow drawn under the dimmed rows and outside the dimmed subtree, so it stays clickable while they are inert — the per-space override editor’s Grid auto-size and Track auto-limit rows, whose switches have no row in that editor (SpacesGateHelp.remote, held to being a live link rather than a Text by GateReasonPlacementTests). Both shapes state the live fact as well as the destination — LayoutCard’s app-bar CrossReferenceRow is the model (“The monocle App Bar (currently on) is configured in …”). Per-control ?s inside a gated block stay visible-but-dimmed like every other row member; the anchor covers the meantime. Guarded by GreyOutAnchorTests.

An auto toggle greys with the slider it governs (#1377). Where an AutoGatedGroup’s slider carries a row gate, the toggle has no effect under that gate either, so the group is wrapped in the gate’s GreyOut and the toggle’s row declares the gate in the census. Auto Glow Size and its slider are the class’s only members today.

The gap masters take that same acknowledging shape over a “mixed” readout (#1383): a grey would name a problem and withhold the one gesture that fixes it. The per-edge drawer under them pre-expands so the values about to be overwritten are in view. A master whose followers disagree acknowledges through the resolver’s followersDiffer and the label’s ?, and never takes a census gate.

Which of the two remote shapes a row takes follows from what is dimmed, not only from whether a live label exists (#1310): a header ? scopes the CARD, so it answers for a greyed block, while ONE greyed row inside a live card takes a CrossReferenceRow directly beneath its grid, outside the dimmed subtree — the Second accent group’s Focused window row, whose gating picker lives on KiwiShelf, draws ColorsAndGlassHelp.focusedItemReference there, and Window marks’ Unfocused windows row draws unfocusedReference the same way while the ring is on, each linking the destination in the sentence. A row with no sentence of its own takes its card’s one link per page whose switch dims it (#1857), and which of these a row owes is its one ColorRowVerdict, which the grey reads too. The argument is docs/design-decisions.md ▸ a dim is not a sentence.

A control whose only consumer is off may still have a second one. KiwiShelf’s “App glyph style” stays live even when no bar shows, because iconSource also drives the shortcuts panel’s Apps section — check for a second reader before dimming.

The tree-wide half of the convention is guarded by suites that scan for a shape rather than listing the greyed controls, since a list cannot see the next site. GreyOutParityTests pins the gates that must be present; GreyOutHidingTests hunts the conditional that removes a control instead of dimming it. The absence side is per-row — no scan can know which control should carry no gate — so a row that earns one cites its own guard where the bullets above do.

Sentinel values read as words, not numbers. A slider gated by an Auto toggle stores 0 as the sentinel but its readout prints “Automatic” while gated — “0 pt” next to a greyed slider reads like a broken value. The full word, not “Auto”: a readout is a value, and the column is widened to hold it (see “Automatic” is the word for a value, above). The slider itself stays floored at 1 so dragging can never write the sentinel (#381).

A control may relabel with the mode it serves. A mode-dependent role gets a mode-dependent name: the scrolling layout’s slot size is Column width on a horizontal axis and Row height on a vertical one (SlotSizeRows) — one stored field, two honest names. Prefer relabeling an existing control over adding a parallel enum value or a second field.

© 2026 KiwiCanopy