Skip to content

Design Decisions

The settled product and design decisions behind KiwiDesk, with the reasoning — so users understand why things behave the way they do, and contributors don’t relitigate (or accidentally undo) a settled choice. Two parts: Architecture & product model (decisions rooted in the engine and config model) and Settings GUI & UX (decisions about the Settings app and menu bar; many from the #68/PR #88 redesign). Deeper rationale lives in the linked issues. The cross-cutting Settings control conventions live in Settings UI patterns; binding code rules and guardrails live in AGENTS.md, not here.

An entry earns its place by one test: a contributor working in that area would otherwise re-litigate it or undo it by mistake. If the code already says what and there’s no non-obvious why, it belongs in git history, not here — so this file stays a design doc, not an event log.

Each entry is tagged with its kind on the line under its heading:

  • [Principle] — a durable rule that constrains future work. Obey it.
  • [Rationale] — why a choice that looks wrong or arbitrary is actually right. Read it before “fixing” the thing.
  • [Trade-off] — a deliberately-accepted limitation (chief among them the reader-facing Accepted limitations page and the Blocked by macOS (SIP) table).
  • [Map] — a cross-cutting table a new feature must keep updated (the layout navigation & overflow models table).

The file is grouped by topic, not by kind, so everything decided about one area sits together. Adding an entry: give it a kind tag; if it can’t take one, that’s the signal it doesn’t belong here.

Product principle: approachable by default, powerful on demand

Section titled “Product principle: approachable by default, powerful on demand”

[Principle]

KiwiDesk should give a new user a good tiling setup with almost no configuration — strong defaults and a handful of obvious controls. That simplicity must never cap what’s achievable: beneath every easy surface is a deeper layer (Lua config, profiles, advanced layouts, per-space overrides) that’s there when wanted and never required to begin. Depth is a capability you grow into, not a cost you pay upfront.

This sits alongside the GUI north-star (AGENTS.md §2 — simplicity, intuitiveness, Apple-native feeling), not inside it: the north-star governs how a surface feels and how to break ties; this principle governs the shape of capability — a shallow floor with a high ceiling. It’s why “simplicity-first” doesn’t mean “underpowered,” and it’s a deeply Apple-native ethos (products that read simple but reward digging in). The read-only shortcuts panel (#326) is the shape in miniature: a dead-simple glance surface, with one “Edit in Settings…” bridge down to the full editor — simple entry, deeper layer one click away, never forced.

Source-available from 1.3.0, under the Business Source License 1.1

Section titled “Source-available from 1.3.0, under the Business Source License 1.1”

[Principle]

KiwiDesk’s source stays public and KiwiDesk is not open source. From 1.3.0 the terms are the Business Source License 1.1: using KiwiDesk stays free, at home and inside a business alike; offering, selling, bundling or hosting KiwiDesk or a derivative as a product or service needs a commercial license from the Licensor, and each version converts to MIT on a Change Date. LICENSE is the authority for every parameter — how far the grant reaches, and how the Change Date is computed — so argue what the license means here and link to it for what it says, rather than carrying a third copy of the numbers. Versions published before 1.3.0 were released under MIT and remain so: a license governs what is released under it and nothing before. (Owner ruling 2026-09-13.)

Why a source-available license, and why before any launch rather than after. Under MIT anyone may take the published code and sell it, bundle it, or host it as a product, and the project keeps no lever over that but its name. A source-available license reserves the offering to the Licensor while the source stays public and use stays free, which is the shape the project wants: readable, forkable, usable anywhere, and not someone else’s product. The grant’s line falls at the offering and not at business use for the same reason: the lever exists to keep KiwiDesk from becoming someone else’s product, not to charge the people who use it, and reserving use inside a business would charge users — the one thing the lever is not for. The timing follows from goodwill. A switch made after a launch spends what the launch earned — people remember the relicense, not the reason — and lands at the moment of maximum attention, on the largest possible free snapshot. Switching before any launch pays the cost once and early, and leaves nothing to walk back: the terms a launch-day reader sees are the terms that stay. BSL 1.1 was chosen over the plainer non-commercial licenses because its terms are the ones developers already know from other source-available projects, and because it commits to a Change Date, which a plain non-commercial license does not.

What the switch does not do. It changes nothing for anyone who just uses KiwiDesk: the Additional Use Grant keeps use free wherever it happens, and the license itself asks for no key, account or payment. It does not touch the vendored Lua or Sparkle, which keep their MIT licenses. And it does not stop a fork of the last MIT version; that risk was accepted the day 0.9.7 shipped under MIT and is the reason the switch is sooner rather than later.

What travels with it. Copy about KiwiDesk states the license rather than promising a price, because a price is a promise and a license is a fact: the bare “free” that described the product was retired the same week (#1375), while “free to use” stays, since that is the grant’s own reach and not a pricing claim. And a line naming the license has to be true of the build the reader can actually download — the site deploys from main while the newest release is whatever was last tagged, so copy beside a download either names the version the terms start at or links to LICENSE, which carries that boundary itself. Contributions are accepted under a relicensing grant stated in CONTRIBUTING.md ▸ Licensing Your Contribution, since a later Change Date or commercial license needs every contributor’s consent otherwise, and the trademark notice on the name and logo is what the license itself leaves open — the license disclaims any trademark grant, so nothing else does it. The boundary is a tagged version rather than a date, so it is the same fact in the release notes, in LICENSE and in a git tag.

The .app is itself a copy of the Licensed Work, so it carries the license text and the Lua and Sparkle notices and opens both from Settings ▸ About — BSL 1.1 wants the License displayed on every copy, and the two MIT notices want theirs in every copy — and a trim of About’s link card or of the bundle keeps them (#1407; how the bundle carries them and derives its copyright line is .claude/rules/packaging-and-release.md’s).

[Trade-off]

Some behaviors are bugs by design — accepted consequences of a settled architectural trade, not defects to fix. The full table — for each: it’s known, here’s why, here’s the architectural root, here’s the real fix where one is planned — lives on its own reader-facing page: Accepted limitations. Its rows link back into the reasoning on this page.

Convention: when a review or manual pass classifies a behavior as accepted-by-architecture, it adds a row there in the same change set — the user-facing twin of the AGENTS.md §5 guardrail rule. A row needs an architectural root and, where one exists, the planned escape hatch; it is not a wontfix dumping ground.

[Trade-off]

A separate class: capabilities macOS forbids without disabling System Integrity Protection. KiwiDesk drives macOS Desktops through private SkyLight/CGS symbols resolved at runtime, and some operations that write the Desktop arrangement are gated by SIP. KiwiDesk never disables SIP or asks a user to — a disabled-SIP requirement is a non-starter for a window manager (AGENTS.md §5), so these stay unimplemented rather than shipping a fragile fast path with no safe fallback. Unlike the Accepted limitations trades, the root is the OS, not our architecture, and there is no in-app escape hatch — only Apple exposing a supported API.

An item leaves this class when a SIP-clean path to it exists, and the entry below on the window-management bridge rules what counts as one. What remains here is tracked, not abandoned:

  • Restore windows across all Desktops on quit (#70).
  • Place a window above the top screen border — the WindowServer silently rejects any frame above the visible area’s top edge. (Partial left/right/bottom overflow is allowed; fully offscreen frames clamp back to a title-bar sliver on every edge.) So a vertical scrolling row scrolled past the top cannot tuck above the screen with its lower strip peeking, the way a true scroll would; ScrollingLayout pins those rows at the border instead — their upper strip peeks — so retile targets stay achievable and the already-there tolerance keeps working. Horizontal scrolling is unaffected (#139; the pin shipped with #66). On the other edges — when no screen lies beyond them — KiwiDesk pins far-offscreen slots at its own fixed sliver, safely above the OS minimum, for the same achievable-target reason (#142); an edge with a screen beyond it is a hard stop instead — a product decision, not an OS limit: see Scrolling at a screen seam under Layout and resize behavior (#878). Stashed inactive-space windows park at the same floor-derived sliver (#148).
  • Pin a foreign floating window above the tiled plane by its window-server levelSLSSetWindowLevel only affects windows owned by the connection that issues it, so KiwiDesk can level its own overlays but not another app’s floats. yabai reaches foreign windows by injecting into Dock.app via a scripting addition (SIP disabled); the own-connection fast path was built and removed once confirmed useless for foreign floats (reference commit 347231e). #418 ships the AX re-raise instead — kept above on focus, with the transient-activation limitation on the Accepted limitations page (#424).

All of these are collected in #140, which is the list to keep in step with this one.

The window-management bridge is not a SIP escape hatch

Section titled “The window-management bridge is not a SIP escape hatch”

[Rationale]

Moving a window to another Desktop and switching the visible Desktop sat in Blocked by macOS (SIP) above for KiwiDesk’s whole pre-1.0 life. They are shipped now, and the rule that let them ship is worth stating, because the next private surface will ask for the same exemption.

The C symbol that moved a window between Desktops was SIP-gated from macOS 15 on; reaching it needs an injected scripting addition, which needs SIP off, which KiwiDesk will not ask for. What changed is not that rule but the OS: macOS now registers a window-management bridge — ObjC operation classes SkyLight dispatches through AppKit’s own delegate — that performs both operations on stock settings with SIP on and without Accessibility trust.

So the test an item must pass to leave that class is a SIP-clean path, not a public one. Private-but-designed is admissible where injection is not, and the difference is not taste: an injected addition rewrites another process on a system whose integrity guarantees the user disabled, while the bridge is a versioned, NSCoding-encoded dispatch surface Apple built for cross-process use, reached through the same runtime resolution every other private path here uses.

Where a Desktop lives on another screen, the verbs act on THAT screen — focus_desktop 3 switches the screen holding Desktop 3, whichever it is.

A follow carries keyboard focus; a plain switch does not, and the asymmetry is the point. macOS attaches focus to a window and never to a screen, so switching a screen’s Desktop is the whole of what focus_desktop can do — there is no window it was asked to take you to. move_to_desktop_and_follow names one, and its own word is follow, so it owes you the window rather than the view of it; move_to_space_and_follow had already settled that for KiwiDesk’s own Spaces, and two verbs spelled alike answering differently is the worse outcome.

Onto a hidden Desktop that focus cannot be handed over at the moment of the move — the window is not addressable until the reveal lists it — so the follow records the debt and pays it the moment the revealed Desktop lists the window again, bounded so a follow macOS declined cannot fire minutes later. The departure itself is the eager fold the transition fix introduced, and it stands KiwiDesk’s own close-return raise down through the one stand-down predicate — handing focus to a sibling of the space being LEFT is the exact opposite of what the verb was asked for (#1007).

The pointer is not a second decision. It follows focus only where mouse follows focus is on, through the same predicate every other focus change uses — the setting is the answer, and a follow does not earn an exception to it.

What that admission costs, accepted deliberately: there is no fallback to write. The public API for these operations does not exist, so where the bridge is absent the verbs refuse and say so — never a synthesized substitute (keystroke-faking Mission Control shortcuts, which depend on shortcuts the user may have changed or turned off). A capability that only the private surface can deliver is allowed to be absent; it is not allowed to be faked. .claude/rules/os-private-apis.md carries that as an obligation on the code.

Distribution: direct download, not the Mac App Store

Section titled “Distribution: direct download, not the Mac App Store”

[Principle]

KiwiDesk ships as a signed, notarized direct download plus a Homebrew cask. The Mac App Store is not a later step, it is out of scope — so a roadmap, badge or landing page should never promise it again.

It sits next to the SIP entry because a reader who accepts that one asks about the App Store next, and both are doors that stay shut. The shared root is only the private symbols, though — the second reason below is economics, unrelated to SIP.

Two reasons, of different kinds — one technical, one economic:

  • Private API. The SkyLight/CGS symbols the section above discusses put Desktop management squarely against review guideline 2.5.1, which permits public API only — and no public replacement exists: detecting that a Desktop switch happened is public, knowing which Desktop is not. Resolving them through dlsym is a robustness measure (AGENTS.md §5: a vanished symbol must return nil, not crash at launch), never a way around the guideline — review scans the binary’s string table, so a compliant build has to compile the resolver out, not disable it.
  • The economics. A store edition is buildable — the 2026-08-18 feasibility pass (#882, the full inventory) found most of the app survives the sandbox: KiwiDesk’s own spaces and the default focus ring are public-API already, crash-restart ports to SMAppService, and Lua-as-local-config is permissible. What it costs is a permanent second product: a split build with its own entitlements, packaging and edition guards, doubled CI, and App Review latency on every release — paid forever, for reach the project does not need and that store search does not deliver a niche utility against Magnet-class incumbents with a decade of ratings. And the losses that do remain (Desktop integration, KiwiDesk.exec, the kiwidesk CLI on PATH) land exactly on the users the product is built for.

Note what is not the reason: driving other apps’ windows through Accessibility is fine sandboxed — Magnet and Moom do exactly that on the App Store. Anyone re-opening this argues the economics, priced with #882’s inventory; the trigger it names is 1.0 shipped plus a concrete demand signal. Every comparable tool (yabai, Amethyst, AeroSpace, Rectangle) is distributed directly.

The practical consequence: notarization is on the critical path, not a nicety. A Homebrew user who meets Gatekeeper runs xattr -d and moves on; someone who downloads a .dmg from the site sees “KiwiDesk is damaged and can’t be opened” and deletes it. scripts/build-app.sh --notarize exists for that (#89), and Sparkle — the replacement for the App Store’s update channel — depends on notarization as well, since it refuses to install an update that lacks it. When a channel may open is a separate question, answered by No distribution channel without an update path.

No distribution channel without an update path

Section titled “No distribution channel without an update path”

[Principle]

Never open a channel a normal user can install from unless that build can update itself. This is about publication, not about a button: the site’s download link and a public GitHub Release asset are the same channel from the user’s side, and a stranded user arrives through either. Building an artifact is always fine; putting it somewhere people find it is what this governs.

The reason it is a rule and not a preference is the asymmetry of the mistake. Sparkle has to be inside the build a user installs — shipping it one version later reaches only the people who install that later version, and everyone already running the earlier one stays stranded on manual re-download forever. There is no recovering the first group, which is why the gate is on publishing rather than on remembering to fix it afterwards.

Homebrew is the deliberate exception, and it is conditional. brew upgrade is a real update path, so a Sparkle-less build may ship as a cask. The cask’s public GitHub Release ZIP is its backing artifact, not a standalone channel KiwiDesk promotes: while Sparkle had not landed, that ZIP was not to be linked from the product site nor advertised as a direct download. Someone who deliberately installs from the repository instead of Homebrew has chosen a manual update path.

The Release must be published before Homebrew can fetch its ZIP, so publication and the tap update cannot be atomic. The accepted failure model is a short, visible stale-cask window: the release is not operationally complete until the Update Homebrew Cask workflow is green. That workflow queues every publication, verifies the published bytes, and permits a retry only when the same version still has the same digest. On failure, retry the workflow or publish a newer version; never replace an existing version’s bytes.

This exception holds only while the release workflow actually bumps the tap — if the cask goes stale the exception lapses and the cask users are the stranded ones. It is an obligation on the cask (#105), not a property that exists for free.

Until Sparkle landed, the two together meant: a Homebrew cask backed by one public Release ZIP yes; a promoted standalone ZIP or .dmg download no. It has landed — the gate below records when, and what that spends.

Trade-off: the first release reaches fewer people. Accepted, and it buys something back — Sparkle’s update path is first exercised against a real previous release instead of being debugged on the release everyone downloads.

Corollary: the updater ships before the release that matters, and ships means published. The trade-off above buys something only if a Sparkle-carrying release exists for the next one to update from. Merge the updater, go straight to the release people arrive at, and the first genuine update is that release to its own first patch — debugged on the largest cohort the project has had, which is the outcome a smaller first audience was being accepted to avoid. So the updater lands in an ordinary release of its own, and the release that opens the channel is one a person can arrive at by updating.

A merged updater nobody has installed from a published release has exercised none of that. A test appcast rehearses the feed parse, the version compare and the install-on-quit; it cannot rehearse signed, notarized, stapled bytes fetched over the network from the production URL, or the cask and the in-app updater not fighting over one install. Which release this binds is whichever one opens the channel, and the obligation holds wherever that lands.

Corollary: the gate is Sparkle-in-the-build, never a version number. The rule above says “until Sparkle lands” and names no version deliberately: what it asks is whether the build a person installs can update itself, and a version number answers that in neither direction. So the question is never “have we reached 1.0”. A release shipped without an updater keeps the channel shut however large its number, and a release carrying one satisfies this condition whatever number it lands at.

It satisfies this one, not the gate. The gate is two conditions and both are properties of builds rather than of a version: Sparkle is in the build a person installs, and — by the corollary above — that build is one they can have arrived at by updating. The first Sparkle-carrying release meets the first and cannot meet the second, which is why a promoted download opens on the one after it and not on a number.

Both conditions have been met, and what the gate guards is spent. A Sparkle-carrying release was published, and a real update from the one before it installed on a physical machine. That is a past-tense fact and cannot come untrue, so nothing above is still a question — read the paragraphs before this one as the argument for the gate, never as a description of a shut channel. #904 is where the confirmation is recorded; it does not belong here.

What that licenses is the channel, not a free pass on the artifact. The release page and the site link are one channel, so a promoted artifact reaches people the moment a release carrying it is published — before any site copy changes. Open the channel in that order deliberately: the release page first, on a release cut to be verified on a clean machine, and the site only afterwards. The reverse strands the one group this whole entry exists to protect, and a stranded downloader cannot be recovered.

What the artifact itself then owes is .claude/rules/packaging-and-release.mdEvery distributable artifact needs its OWN ticket, which owns both the obligation and how to verify it.

Background update checks are on, and there is no switch

Section titled “Background update checks are on, and there is no switch”

[Rationale]

KiwiDesk checks for updates in the background, Info.plist says so with SUEnableAutomaticChecks, and no Settings row, Lua verb or census key lets a user turn it off.

The alternative is not “no prompt”. Left unset, Sparkle asks the question itself — a modal, a few seconds after first launch, from an app with no Dock tile to explain where the dialog came from and quite possibly on top of the first-run tour. That is the worst version of offering the choice: it arrives before the user knows what KiwiDesk is, and it is the first thing the app ever says to them.

Answering it in the plist is what approachable by default means here. An updater nobody remembers to run is not an update path, and this project’s whole distribution argument (above) rests on installed copies actually moving forward.

What is given up, stated rather than glossed: a Mac app that checks automatically normally offers the toggle, and .claude/rules/gui.md’s north star is Apple-native behavior. This is a deliberate exception to it, taken because the toggle’s only shipped form was a modal at the worst moment. The check sends nothing about the machine — Sparkle’s system profiling stays off, so it is a plain versioned GET — which is what makes the missing switch a preference question rather than a privacy one. If it were sending a profile, this ruling would go the other way.

What would reopen it: a Settings row is the answer whenever someone builds one, and this entry is not an argument against it. It is the answer to “why is there none yet”, so a future row supersedes this without contradicting it. What must not happen is unsetting the key and letting Sparkle ask again.

Scheduled update reminders are a mark, not a notification (#1013)

Section titled “Scheduled update reminders are a mark, not a notification (#1013)”

[Rationale]

For a background app Sparkle draws a scheduled update alert behind every other window — deliberately, so the offer does not take the screen — and for a menu-bar app with no Dock tile that is drawn nowhere. Sparkle logs the warning once per launch. The background check is the path most users are on, so an alert nobody sees is an update path that does not deliver.

The reminder is a mark on the status item and a row in its menu, nothing else: UpdatePromptPolicy declares gentle reminders and answers that KiwiDesk shows every scheduled update itself — whatever focus Sparkle proposes, since the accessory-mode corollary forbids an unsolicited offer taking the screen — and the status item carries a dot until the update gets attention or the session ends. The dot is composited into a fresh image with a knockout ring, the SF Symbols .badge idiom, top-trailing and Ø5 at the 18 pt master, and it is orange — the owner ruled colour over the monochrome template the design pass proposed (2026-09-08). What that costs, stated: a template carries no hue, so the composite is not one, and the bar’s highlight inversion while the menu is open no longer reaches it; what it keeps: the drawing handler resolves the bar’s label colour and systemOrange at every draw, so light and dark still follow, and the shape alone still separates the mark for colour-vision deficiency. Nothing moves, so Reduce Motion has nothing to gate. It rides only the healthy glyphs, because a permission warning or a config error outranks an offer. The updates row is retitled in place — “Update Available…” — rather than doubled: Sparkle’s own door for bringing the waiting alert forward is checkForUpdates, the row’s existing action, and canCheckForUpdates stays true while the update waits — Sparkle 2.9.6 counts the update as shown the moment its driver is handed it, before the delegate’s answer is read (SPUUIBasedUpdateDriveruiDriverDidShowUpdate, which is what SPUScheduledUpdateDriver.showingUpdate answers from), and that flag is the one checkForUpdates routes on.

A user notification was ruled out. KiwiDesk’s one notification is the permission-lost notice (AppDelegate+Notifications.swift), and the authorization prompt it costs is asked at the moment window management stops — a reason the user can see on screen. An update reminder would spend that prompt on an offer, on top of a first-run story that is already a permission wizard, to tell someone about an update. No setting was added — the reminder costs nothing to ignore.

Linking the notes is not opening a channel

Section titled “Linking the notes is not opening a channel”

[Rationale]

The rule above governs acquisition — where a person who does not yet have KiwiDesk, or whose copy has gone stale, goes to get one. That is why it is phrased about publication and promotion rather than about links: what strands a user is arriving at an installable artifact by a route that cannot update itself.

A link labelled for the release notes serves the opposite reader: someone who already has the app, opening it from inside their own copy, to find out what changed in the version they are running. It recruits nobody into an unmanaged update path. So the rule does not reach it, and the label is what decides which of the two a link is — not the destination’s file listing, which GitHub composes for every project alike.

Both halves matter, because two different mistakes follow from dropping either:

  • Read the rule as reaching any link at all, and the app can never tell a user what changed — not until Sparkle ships, which is indefinite. The alternative someone reaches for next is an in-app notes reader, which is a new surface duplicating rendering GitHub already does better, built to satisfy a rule that was never about reading.
  • Read “the label decides” as licence, and the row drifts toward the download it must not become. So the obligation is on the words: a surface pointing at the releases page stays named for the notes. Never retitle it to Download, Get, Latest or New Version, and never point it at a release asset rather than the page. Those four words are the line, and crossing it is what turns an informational pointer into the promoted standalone download the rule above forbids.

Trade-off: a reader who follows the link does meet the ZIP, one scroll below the notes. Accepted — they are already installed, so the asset is at worst redundant to them, and the alternative costs every user the ability to see what changed in order to hide an artifact from the people least likely to need it.

When Sparkle lands it will show the current version’s notes on update. That does not retire this link: Sparkle answers “what is in the update in front of me”, and this answers “what changed across every version, whenever I ask” — including for a user who skipped four of them.

Release notes are written for the person installing

Section titled “Release notes are written for the person installing”

[Principle]

A release note names what the reader will notice; the mechanism belongs in the PR that carried it. The first draft of 0.9.7’s highlights said “the ring’s work no longer starves the main actor”, “the layouts place the residue” and “31 interpolations across 472 values”. Every clause was true and none was legible to anyone who had not read the diff. The same three, rewritten: “the focus outline keeps up”, “KiwiDesk arranges the others around it”, and “a batch of sentences that couldn’t be phrased naturally in other languages have been rebuilt so they can be”.

The test is neither word count nor tone. It is: would a reader who has never seen this codebase recognise the thing described as something that happened to them? An internal noun — the engine, the tiler, a retile, the main actor, a residue, an interpolation — fails by construction, the reader having no referent for it. A symptom passes. The rule is easy to lose because the person writing the notes has just spent a week inside the mechanism, and the mechanism is what feels notable to them.

Two consequences fall out, both structural rather than stylistic:

  • Highlights carry no issue or PR numbers. The generated “What’s Changed” list sits directly beneath them and is the complete record, every entry linked. Numbering the highlights as well makes the reader’s eye redo work the section below already did, and turns news into a bug list.
  • Highlights are highlights. Twenty bullets is a changelog with headings, and a nine-line bullet is a PR description. A bullet is ONE line: the thing you would notice, and that it is fixed. A second sentence is earned only when one line cannot say it — a default that changed, a control to go and find — and never by the diagnosis, which is the PR’s (ruling 2026-09-08). The whole block should read in one screen of the update sheet Sparkle shows.
  • A site change is news only when a visitor would come for it. A new page, a new language, a changed download earns a line. A heading that now fits its column, a corrected term, a font bump and release plumbing earn none — not even a closing “on the site” line, which 1.2.1’s first draft carried and the owner struck (ruling 2026-09-08): a reader installing an update has no reason to care that the website was tidied. The generated list still carries each of them for whoever wants that.

This binds whichever surface carries the notes, not the surface it happens to be today. That surface is currently the GitHub release body — the entry above rules it is this project’s changelog — and when the curated changelog page lands (#873) for Sparkle to render (#874), the page inherits this rather than restating it.

No guard is proposed, and that is a ruling rather than an omission: nothing mechanical separates “the focus outline keeps up” from “the ring no longer starves the main actor”. Both are well-formed prose about the same commit. A banned-word list would fail open on the phrasing it did not anticipate and fail closed on the same nouns used legitimately elsewhere. This is a review-time rule, and this entry is where the reviewer is sent.

A highlight describes what shipped, not what comes next. 0.9.7’s draft opened “The last beta before 1.0” and it was struck before publishing. That line broke no rule above — it names nothing internal and a reader understands it perfectly — which is why it is worth its own clause: the defect is that it is a forecast. A description of what shipped can only be wrong on the day it is written, and review catches that. A forecast is falsified later, by events somewhere else entirely, and nothing notices — the same failure .claude/rules/rule-authoring.md names when it asks for an obligation instead of a state claim. A release body is also the surface least able to absorb it, being immutable in practice once people have read it and mirrored by every tap and feed that carries it.

So no roadmap position, no “next up”, no promise about the following release. Whether 0.9.7 turned out to be the last beta was not knowable on the day it shipped, and the notes did not need to answer it.

A fix to something that has not shipped is not news; it is part of the thing it fixes. 1.2.0 brought Liquid Glass to every surface, and four commits between then and the cut corrected its tint channel, its light/dark variant and two of its rendering paths. Listing those reads as a feature that arrived broken — and no reader ever met the broken version, because none of it had shipped. They belong inside the feature’s own bullet, or nowhere. The test is the same one this entry already asks, applied to a version rather than a person: was the defect reachable from the last release? If it was not, the reader has nothing to recognise.

The same reasoning retires a whole class of entry that keeps appearing in a first draft: work whose subject is this release’s own making. Translating sentences this release introduced, re-vendoring a font, wiring the release pipeline — each is real work and none of it is a change the reader experiences.

The API describes itself, and its enums are read not typed

Section titled “The API describes itself, and its enums are read not typed”

[Principle]

The signature of every Lua/CLI command — its group, its arguments, the legal values of an enum argument, and a one-line summary — lives in APIReference as data, beside the names that were already there. It does not live only in docs/lua-reference.md.

The pull toward prose is real, and it is what shipped first: the names were a Swift table that “can never drift from the real API”, while the signatures were 4,800 lines of hand-written Markdown that could, and did. list_commands therefore answered “what can I call” with 262 bare names on one line — no groups, no arguments, no summaries — and list_commands focus answered the same 6.9 KB, because the argument was read and dropped (#1033). The doc could not fix that: a running binary cannot consult a Markdown file, and a user in a terminal should not have to.

Two rules fall out, and both are guarded.

An enum argument’s legal values are READ off the decoder. APIArgument.choice takes a metatype, and APIChoice has exactly one initializer, which reads allCases. There is deliberately no way to hand it a list. This is not tidiness: the error message the bar setters print already disagreed with their own decoder — the code said ring|edge_mark|gap while the enum had renamed that case outline — and a listing hand-typed the same way would have inherited the same class of lie, with more readers. The compiler enforces the derivation today; APIChoiceDerivationTests scans the declaration, because adding a second, list-taking initializer is a two-line change that compiles and reads harmlessly.

A record carries neither its own name nor its group. Both are the key it is filed under, so the names stay one list rather than two, and APIRecordCensusTests holds the key sets against commands / namespaces / luaOnly in both directions — parity-tests.md’s forget-proof shape, and the reason the remaining records can be filled in bulk by someone who did not design any of this.

help is answered by the CLI itself, not over the socket. The listing describes the API a binary was built with; no app state enters it, and APIReference is compiled into the same binary the CLI is. Round-tripping it would buy nothing and would make kiwidesk help focus fail exactly when a user reaches for it — while the app is not running, which is when you are most likely to be reading about a command rather than issuing one. --version is answered locally for the same reason. The cost is named rather than hidden: an older app running under a newer kiwidesk on $PATH is described by the newer one, which is a half-finished install rather than a mode of operation. To keep “local” from becoming “second”, both answers come from one function — APIReference.helpResponse, which the dispatcher’s help case also returns — and CLIHelpSeamTests refuses the CLI tree any reading of the name tables.

What this deliberately does not do is generate docs/lua-reference.md. That doc carries argument ranges, defaults, worked examples and the macOS caveats behind them; a one-line summary is not a substitute, and pretending otherwise would trade a drift problem for a much worse documentation one. Generating its signature tables from this data is a genuine follow-up, and it is the reason the data is shaped this way.

The landing page argues from the papercut, not from the mess

Section titled “The landing page argues from the papercut, not from the mess”

[Principle]

The Simple-mode landing copy argues for KiwiDesk from specific macOS frustrations a stranger recognises instantly, never from “your windows are messy”. Tidiness is a cleanup pitch, and nobody goes looking for a window manager because their screen looks untidy — they go looking because something cost them time today.

Four constraints fall out, and they are the durable part:

  • The papercut has to be one KiwiDesk actually solves. This is the trap, and the first draft fell straight into it: the green button is a real grievance and KiwiDesk does not fix it — docs/user-guide.md ▸ native fullscreen says it stands down around such a window entirely, and macOS still gives it a Mission Control slot of its own. Arranging windows by hand IS solved, by default, for everyone, which is why the section argues that instead. Check the relief before writing the grievance.

  • The picture argues too. The before/after art carried the retired claim as scattered rectangles, and re-lettering the cards while that stayed would have shipped the ruling half applied — a reader believes the picture first. Both frames now draw the same windows; what differs is only how well they fit. site/src/styles/landing-modes.css owns how many and where.

  • The honest before is not chaos. It is doing it by hand and not realising there was another way. Copy that tells readers their desk is a mess describes someone else.

  • A papercut is translated, not pasted. README.mdSolving macOS Papercuts writes them for people who already know “monocle”, “spaces” and pull_or_spawn. Simple mode gets the symptom and the relief, never the mechanism — and never a claim the app does not make. Two the first draft got wrong: KiwiDesk does not change what ⌘Tab does, and nothing seeds a keystroke that makes a window big — so anything reached through a binding is written as an offer, never as behavior.

Not every papercut survives the translation. macOS reshuffling your Desktops was dropped rather than reworded: the honest version needs a qualification the section cannot carry, since what KiwiDesk offers is its own spaces in fixed slots and no doc claims it stops macOS reordering anything.

Trade-off: the section speaks to people who have hit these specific things rather than listing everything. Accepted — a stranger who recognises one papercut instantly is worth more than four they have to qualify for, and a page that lists grievances in a row reads as a complaint.

Section titled “Two install paths, one recommended per mode — never a chooser”

[Principle]

The site offers both a direct .dmg and the Homebrew cask, and it never asks the reader to pick between them. Each mode leads with one and keeps the other quietly available: Simple mode leads with the download, Nerd mode keeps Homebrew first where it already was, and the guide leads with the download while keeping a full, uncollapsed brew block for returning cask users.

The reason a chooser is wrong here is that the page already asked this question once. The Simple/Nerd toggle is the “which of these two people are you” control, and a side-by-side install card asks it a second time in a place where the reader has no basis to answer: a stranger does not know what Homebrew is, and someone who uses it does not need the comparison.

What removes the residual anxiety — does it matter which one I pick? — is one sentence rather than a badge or a “recommended” ribbon: it is the same signed build either way, and it keeps itself up to date from there. That is true, and it is the whole mechanism.

One real difference survives, and it is stated once, on Homebrew’s side in Nerd mode only: the cask links the kiwidesk CLI onto $PATH for you. State it as what Homebrew adds, never as the disk image lacking the CLI — that is false, since the CLI is the app’s own executable and ships inside every copy. It is not surfaced in Simple mode or in the guide, because a reader with no use for the fact would meet it as a decision — the precise failure this entry exists to avoid. docs/cli.md owns what a .dmg user does about it, and that answer has to exist before the difference may be named: a caveat with no resolution is a dead end rather than a difference.

[Principle]

Restoring a backup replaces the settings, the profiles and the palette library outright. It does not reconcile them with what is already on the machine, and it must not grow the ability to.

Merging sounds kinder and is worse. It needs a collision policy per profile name and per palette name, then a rule for a setting that differs, then a way to show the user what it decided — and at the end of all that the result depends on what happened to be on the destination Mac, which is precisely the variable the user was trying to eliminate by carrying a backup over. “The setup I exported” is a thing a person can picture; “the setup I exported, reconciled with whatever was here” is not.

Replacement also makes the promise checkable. After a restore the destination holds exactly what the source held, so a user can confirm it by looking, and a test can assert it without modelling a merge. What is replaced goes to the Trash, so the cost of being wrong is one drag rather than a reconstruction.

The same reasoning puts the restore at the end of the Advanced drawer’s severity ladder rather than beside its export. Reset All Settings is named for what it spares — init.lua and the colour palettes visibly survive it — so an action that replaces the palettes too is strictly the wider one. Ordering it before Reset All would put the harsher action above the milder and break the only thing that ladder communicates. The price is that the two halves of one feature sit apart, which is accepted: a user who has just exported is not in danger, and a user reaching for the bottom of that drawer should meet the most severe thing last.

Trade-off: someone who wants one profile from an old machine has to restore everything and delete the rest. Accepted for now — per-profile export is a smaller, separate feature, and ProfileManager already has the primitives whenever it is wanted.

Feature names: which stay English, which translate

Section titled “Feature names: which stay English, which translate”

[Principle]

“App Bar” and “Space Bar” are the same in every language; the layout mode names are not. Which family a name joins is decided by one checkable question — does this thing’s own label key ship untranslated in all eleven catalogs? — and the two families are enforced by deliberately opposite-shaped guards: one requires the English name to be present, the other requires it to be absent.

That policy has its own page, because it is a rule a translator must follow and a decision a maintainer must not undo, and because the failure it prevents is invisible to anyone reading a language they do not speak:

Feature name policy — the families, what each requires, why script is irrelevant to one and decisive to the other, and what to do when adding a name.

One concept, one word — and why that one is not guarded

Section titled “One concept, one word — and why that one is not guarded”

[Trade-off]

A feature name is decided once for all eleven languages. An ordinary word is not: layout, gap, profile, shortcut have no label key of their own, so nothing in a catalog declares which of a language’s two candidates KiwiDesk means. Six catalogs were shipping two or three words apiece for one concept, and the split fell between adjacent surfaces — a tab bar and the help text under it, a destination label and the menu item that opens it — where a user meets both in one glance.

The decision has two halves, and the second is the one a maintainer would otherwise undo.

The word is chosen by a ranked ladder, not by a table. A candidate that already names another KiwiDesk concept in that catalog loses whatever its count — a label reusing another feature’s noun does not read as inconsistent, it reads as true about the wrong thing, which is how a Simplified-Chinese profile search returned a result labelled configuration file. Otherwise the catalog’s own occurrence count decides, and a near tie goes to the destination label, that being the name the user learns. Writing the procedure rather than its output is deliberate: an eleven-column table of winning words would be a copy of the corpus, and a copy of the corpus rots against it on any commit, while the count rule makes each catalog its own register.

No content guard can enforce it, and one narrow guard can. The obvious predicate — a banned-rival register per locale — dies on a fact that only shows up once the sweep is done: every losing word is still correct somewhere else in the same file. Spanish «espacio», Italian «spazio» and Portuguese «espaço» each name a Space in about a hundred keys; Korean 연결 means connected; Chinese 配置文件 is right in the one key the ruling exists to protect. A ban would fire on hundreds of good values, and scripts/localization_guards.py has no exemption file by policy, so it would be reverted or given a baseline within a week.

The mistake worth not repeating is generalising from that to no guard at all, which this ruling did on its first draft. The sub-class where the collision is byte-identity needs no vocabulary: compare two strings the same catalog ships, the way the breadcrumb guard already does. DestinationNameCollisionTests does exactly that for destination titles, and it fires on the zh-Hans Profile defect this work was chartered to fix. It lives in Tests/ rather than in the guards script because a Swift suite may carry a reasoned exemption map — the standing idiom here — so the one legitimate pair is excused in writing rather than switching the guard off. Partial cover of the worst sub-class is not a consolation prize; it is the sub-class.

What was done instead is worth more than the guard would have been: the two adjacent classes were made unwritable rather than scanned for. A breadcrumb is held against what each segment’s own key renders, and English prose that names a pane or a role now interpolates that label’s key instead of quoting it (#818), which puts the anchor under placeholder_drift — an exact contract that already runs — in every locale forever. The residue, one language’s two ordinary words for one idea, stays with review, and the ladder is what makes that review cheap: a reviewer who does not speak the language can still check a grep.

Rule 1 takes a word away and has to say what replaces it. Left unanswered, the obvious move is a second ordinary noun, which is the defect the family exists to stop — so the escape is ranked as well, and its first step is the one that keeps surprising people: check the destination label is faithful before working around it. English qualifies a generic destination noun (“Layout Defaults”), and a catalog that rendered it bare has not discovered a shortage — it has mistranslated the destination, and taken the ordinary word out of circulation as a side effect. Restoring the qualifier gives the word back.

Where the shortage is genuine, the ordinary site qualifies and the destination never moves, which is rule 3 read in the other direction: the destination label is the one string that is a card title, a back chip and a search row at once, so it is the last thing that should absorb a collision it did not cause. And the qualifier is a noun rather than a verb, for the reason the ladder’s own step 3 gives and this entry does not re-argue. That difference is measured in points on a button, which is why the width half of it is an obligation in .claude/rules/localization.md rather than advice here.

The ladder, the escape and the counted legitimate uses are in Feature name policy ▸ Family C.

Vocabulary: macOS has Desktops, KiwiDesk has Spaces

Section titled “Vocabulary: macOS has Desktops, KiwiDesk has Spaces”

[Principle]

One word named three things. macOS’s Mission Control desktops, KiwiDesk’s own workspaces, and any generic screen area were all “space” — and the first two turn up in the same sentences, so every explanation of a feature touching both had to disambiguate before it could say anything. The README reached for “Virtual Spaces … on top of native macOS Spaces” to do it.

The ruling: macOS’s are Desktops, KiwiDesk’s are Spaces. The qualifier “virtual” goes with the ambiguity it existed to hold off. The generic screen-area sense and the kernel/user-space sense are reworded away entirely — neither may use the word at all. Every remaining bare “space” therefore means KiwiDesk’s; a sentence that names macOS’s says Desktop, and one that names both says both words. “It is clear from context” is not a defence: a sentence readable either way is the defect this rule exists to remove.

KiwiDesk’s side of the wire never moved. No Lua verb, no JSON key, no Swift type, no event name naming KiwiDesk’s spaces — focus_space, SpaceID, space_modes and space_bar.* all stay; the Space Bar keeps its name, being KiwiDesk’s own bar showing KiwiDesk’s own spaces.

macOS’s side of the wire moved once, after 1.0. The ruling above originally froze it too — bind_profile_to_native_space kept its name “since native already disambiguates it” — as a cost call made pre-release, when no migration and no broken init.lua was the whole argument. It was lifted on 2026-08-25, before the native Desktop verbs (#884) landed beside it: a wire reading …native_space in three places and …desktop in the new verbs would have carried the one-word-two-senses defect this ruling exists to remove, and the cheapest day to unify it was the day before it hardened under a userbase. So the verb is bind_profile_to_desktop, the event desktop_change, the get_state field desktop, and the Settings copy keys desktops.* — with no alias (AGENTS.md §5: a renamed verb gets no compatibility layer; the 1.1.0 notes say what changed). What did NOT move, deliberately: Core’s NativeSpace / NativeSpaces types, which model WindowServer spaces — fullscreen and system spaces included — of which a Desktop is only the user-type kind.

Why the macOS sense is the one that moves — and what does NOT decide it. It is tempting to say “Desktop is Apple’s word”, and that claim does not survive contact with Apple’s own UI. Apple uses both, for different things: the FEATURE is Spaces — the System Settings checkbox reads “Displays have separate Spaces” and the Keyboard ▸ Shortcuts rows read “Move left a space” — while each INSTANCE is a Desktop, labelled “Desktop 1” / “Desktop 2” in Mission Control and settled in the “Desktop & Dock” pane. So deferring to Apple resolves to no single answer, and anyone re-opening this on the grounds that Apple says Spaces is half right; they should read the next paragraph rather than this one.

What the instance label does buy is that “Desktop n” is the word on screen at the moment a user is looking at the things, which is what a binding row names. KiwiDesk’s own copy had already reached for it: desktops.intro (then native_spaces.intro) read “Each Desktop is a native macOS Space from Mission Control.” until #768 — one sentence stating as an identity the very thing this ruling splits.

Cost is what actually decides it. The conflict is irreducible: two systems, one word, and one of them has to move. 119 English strings named KiwiDesk’s spaces against 6 naming macOS’s, each carried by ten non-English catalogs, so renaming ours would have billed ~1,190 translated values; renaming macOS’s side billed the 3 of those 6 whose meaning actually changed, at 30 (measured for the ruling, 2026-08-07; #765 carries the count for the alternative and is closed). A forty-to-one cost ratio decides a question that terminology alone leaves open.

This is also why the Apple-verbatim carve-out is not an inconsistency but the same rule applied: where copy NAMES one of Apple’s controls it uses Apple’s word for that control, “Spaces” included. .claude/rules/config-vocabulary.md carries the obligation.

It is reversible, and this pass makes the reversal cheaper. If the ambiguity still bites later, renaming KiwiDesk’s side stays available: a tree where every sentence already states which sense it means turns that rename from a page of judgment calls into a mechanical one.

Residual risk, stated rather than hidden. The tiling-WM community says “space” for the macOS concept — yabai’s whole API does — so a bug report reading “my space broke” stays ambiguous, and a reader arriving from another tool carries the other meaning in. This rule manages that; it does not eliminate it. Eliminating it is precisely what renaming KiwiDesk’s side would buy, at the bill above.

Names already eliminated, so that none is proposed again. The counts are as measured when the ruling was taken (2026-08-07):

CandidateKilled by
zoneStack’s master/stack zones (~88 sites) and drag.drop_zone.* (~172 sites, user-typed Lua) — two prerequisite renames to free one word
deskSubstring of “KiwiDesk” (44 hits) and “desktop” (11) — a presence guard on it passes vacuously, and it collides with the word being separated from
boardSubstring of “onboarding” (18 key hits)
paneSubstring of “panel”
tile“tiling” / “tiled” (15 hits)
shelfPaletteShelf in source
areaSettingsArea is the #678 redesign’s central noun (271 hits in Sources/)
workspaceEvery competing tool’s word for the same thing
roomAlso means available area — “no room in the room”; substring-satisfiable in any presence guard
deckNothing. Zero hits across all 971 English strings — the pick had the answer been “rename ours”

(#768; the declined alternative — renaming KiwiDesk’s side — is #765.)

Vocabulary: a screen is a screen, and display is Apple’s word

Section titled “Vocabulary: a screen is a screen, and display is Apple’s word”

[Principle]

The same shape as the ruling above, one noun over, and it went unruled for longer because no single word was obviously wrong. English shipped three for one thing — screen, display, monitor — interleaved across adjacent surfaces rather than separated by area. Profiles is the whole defect in one pane: its caption says a profile is “remembered per display arrangement”, the preset outline below it labels a screen “Main screen”, and the Home card that opens the placement picture is called “Monitors” — three words for one thing, in one glance.

The ruling: a physical screen is a screen. Display is reserved for quoting Apple’s own controls. Monitor is retired.

Why display is the one that cannot stay, and this is what makes the ruling more than a coin toss between three synonyms: display is already spoken for twice. It is Apple’s noun — the Displays pane, the “Displays have separate Spaces” checkbox that copy must quote verbatim — and it is KiwiDesk’s own verb in “Display language”. A word doing three jobs cannot be the one that names a screen, by ladder rule 1, before any count is taken. That leaves screen against monitor, and there the count is decisive rather than close: measured for this ruling (2026-08-17, against en.json at fcd52b6d, word-bounded over values and plurals included, so screenshot and monitoring are not in it), values said screen 44 times against monitor’s 25. #865 carries the measurement it was taken from.

Reserving Apple’s word is the same move the Desktop ruling made, and for the same reason. Where copy sends a user to a control someone else named, it must use that control’s name or the sentence fails at its one job. Keeping display free for that is what lets the rest of the corpus have a word of its own — exactly as reserving Desktop for Mission Control is what lets every bare “Space” mean KiwiDesk’s.

The destination label loses, which is worth stating because it feels backwards. Family C’s rule 3 hands a near-tie to the destination label, on the grounds that it is the name the user learns first. This is not a near tie, so rule 2 settles it and “Monitors” is a losing word in the most-read position — the same shape as ko’s gap destination, which shipped a transliteration while the rest of that catalog already carried the ordinary word, and was swept to it rather than the other way round. A pane whose every sentence says screen while its card says Monitors is the split, not a mitigation of it.

The ruling and the sweep are two decisions, and only the first was taken here. Deciding the winner costs a paragraph and makes every string authored afterwards correct; sweeping the existing ones reaches the settings census, a component directory, the site corpus and docs/, and it touches the wire wherever a Lua verb, an event name or a profile key spells one of the two words — which is its own ruling, and a set this entry derives rather than lists (grep -E 'display|monitor' docs/lua-reference.md docs/cli.md answers it, and answers it again after the next verb lands). Taking the ruling without the sweep leaves the corpus knowingly inconsistent rather than accidentally so, which is the cheaper of the two states and the only one that converges. The sweep is #865, off 1.0; the English-side obligation is .claude/rules/config-vocabulary.md ▸ noun glossary.

What this does NOT decide: any catalog’s own word. Ruling the English winner tells zh-Hans nothing about 屏幕 versus 显示器 — each catalog runs Family C’s ladder over its own file, and its answer can legitimately be the cognate of a word English retired. Reading an English ruling as a translation instruction is how a sweep breaks correct copy.

[Map]

Two facts about each layout are invisible without reading its implementation, yet several cross-layout behaviors turn on them: how it navigates (a geometric neighbor search over calculated slots, or an array-order step along the flat window list) and whether it can produce an overflow pile (an OverlapStack cascade it falls back to when windows stop fitting at min_window_size). This bit the swap-skip-cascade fix (#172), which needs a geometric path and a separate array-index path — and track was nearly mis-classified as “already fine” because its array navigation plus new overflow piles (#128) were written down nowhere.

There are exactly two navigation models, and every layout is one of them: geometric (a neighbor search over calculated slots — BSP, Stack, Grid) or array-order (steps the flat window array — Scrolling, Monocle, Track). The “how” column below names only how that one layout walks its slots — which axes it steps, cycle vs step, any cross-axis fallback — a detail of the same model, not a further model. Grep the cited symbol for detail:

LayoutModelHow it walksOverflow → pile?
BSPgeometricNavigation.neighbor over slotsyes — an extreme stored ratio cascades the whole space (BspLayoutOverlapStack)
StackgeometricNavigation.neighbor over slotsyes — a zone overflow cascade / cascade_all (StackLayout); piles always cascade downward, whatever the arrangement (#222)
GridgeometricNavigation.neighbor over slotsyes — a last-cell pile (rigid/dynamic past the cap) or a whole-grid cascade at min-size (GridLayout)
Scrollingarray-ordersteps along the scroll axis (scrollingStep), geometric fallback cross-axisno min-size cascade — the edge pile (#142; walled at a screen seam, #878) is a viewport pin, not an OverlapStack fallback
Monoclearray-ordersteps along the orientation, wraps iff wrap_focus (monocleCycle) — same 1-D shape as scrollingno — every window shares one frame
Trackarray-ordersteps both axes (trackStep)yes — surplus tracks merge into one far-edge overflow track (OverlapStack) shaped by overflow_style (#192, default cascade_all); normal tracks always cascade_overflow
Floatinggeometric (live frames)Navigation.neighbor with no slots: every member navigates by its live frame (the slot→frame fallback), flagged floats via the #488 float tiern/a

The two models need different handling for anything pile-aware: geometric layouts exclude the focused window’s pile-mates from the candidate set, array-order layouts skip their array indices (#172). Both share one geometric detector, Navigation.pileMates.

Orthogonal to both models, directional focus (never swap) runs a two-tier candidate search (#488): tiled candidates first — the model above — and, only when no tiled window lies in the pressed direction, the space’s floating windows by their live frames (StateCoordinator.floatingFocusCandidates: float-flagged members plus floating sticky windows rendering on the space; transient overlays and fullscreen windows never). Tiled-first keeps tile-to-tile navigation untouched while removing the directional black hole a visible float used to be — dropped from effectiveTiledMembers, it could navigate out (the anchor falls back to a geometric search from its live frame) but nothing could navigate back in. Array-order layouts reach the float tier through their existing edge fall-through to the geometric search.

flowchart TD
    L["a new layout"] --> M{"navigation model?"}
    M -->|geometric| G["neighbor search over<br/>calculated slots"]
    M -->|array-order| A["steps the flat<br/>window array"]
    M -->|none| N["no slots — n/a"]
    G --> P{"can it produce an<br/>OverlapStack pile?"}
    A --> P
    P -->|yes| Y["pile-aware handling: geometric EXCLUDES<br/>pile-mates, array-order SKIPS their indices<br/>(shared Navigation.pileMates)"]
    P -->|no| Z["no min-size cascade"]

Tiled-sticky injection (#414 v2) rides the models above with zero per-layout navigation work: a tiled-sticky window homed on another space is injected into the active space’s tiled member array (StateCoordinator.effectiveTiledMembers, derived home-index insertion), so geometric layouts see its slot as an ordinary neighbor candidate and array-order layouts step through its index like any other. The one place the injection is not enough is what a focus-driven layout surfaces (#431): a Scrolling space pans to context.focused and a Monocle space raises it (restoreMonocleZOrder), but the traveler can never be the active space’s membership-guarded focused slot, so focusing it (a bar-item click, a keyboard navigate-to) left the viewport put — or the window buried under the space’s own local window. StateCoordinator.focusAnchor closes the gap: while the traveler is the frontmost window it surfaces instead of space.focused. lastFocused is global, so the anchor tracks the last-focused window across every space and yields the traveler until any real member is next focused — a bare space switch does not revert it on its own (it fires no focus event). Directional focus/swap and the other implicit-focused verbs (toggle_floating/make_*, move_to_space) resolve their target through this anchor too — the #431 rewire and the #292 foreground guard both read focusedWindowID — so a frontmost traveler is the origin/target, not the stale local slot it can never occupy. A keyboard reorder that cannot apply to a non-member (swap, track.swap, stack.promote/demote, move_to_track) refuses with the home-space pill (#435) rather than silently no-op. resize is the one exception, staying on space.focused to avoid orphaning a per-space weight under a non-member id (see Accepted limitations). The App Bar highlight has the same root and the same shape (#431): its focused item and group expansion read KiwiCore.appBarFocused, which on the active space prefers the system frontmost (lastFocused) so a traveler’s item lights up, while every inactive-display space keeps its own remembered focused; the Space Bar already carried this fix (#414, it reads raw lastFocused because its items are spaces). What does differ per layout is the overflow pile: a sticky window keeps a fully-tiled slot, so the partial tile-then-pile overflows — Stack zones, track columns (cascade_overflow), and the grid’s last-cell pile — clamp it below the boundary via the shared OverlapStack.stickyExempt (a trailing non-sticky window piles in its place). Whole-region cascades (cascade_all and the emergency min-size fallback) exempt nothing (no fully-tiled slot exists — see Accepted limitations); Scrolling has no OverlapStack pile at all — its overflow is the scroll, and the clamped edge columns (#142/#150) are scroll-reachable viewport pins a sticky may sit in like any other slot, not cascades — and Monocle overlaps everything at one frame, stacked full-frame or parked at the stash corner under hide_style = park (#881), so both need nothing. Reorder of a traveler is home-space-only: Space.swap /move/bar-drag membership guards no-op on a non-member by design (v2 non-goal; see Accepted limitations). A new layout adding a row above must also state which pile class it produces, so the sticky exemption is reconciled with it.

The focus border (#278) is a cross-layout overlay that deliberately opts OUT of the pile-dedup model above: with border.unfocused_enabled, every tiled window gets its own ring, including every member of an overflow cascade. Buried rings naturally show only along their exposed cascade edges because each overlay is ordered directly behind its target window. The stroke geometry overlaps under the target to prevent a detached seam, while the target masks that overlap so the border never covers content. A popover, sheet, or emoji picker above the target naturally covers the ring too. This is a border-only presentation policy: Navigation.pileMates remains the shared authority for navigation, swaps, and z-order restoration. In monocle — where only the focused window is visible — borders stay focused-only. The focused window is ringed whether tiled or floating.

Floating windows are in the unfocused set too, flag-floats and floating-mode members alike — the #1286 entry below carries the argument; they were excluded here without one.

A transient overlay — a window that floats for a structural reason (accessory activation policy, a non-standard panel subrole, or a raised CGWindow layer) rather than a matched float_rules entry — never receives a ring, even while it holds focus (#300). The suppression is a draw-time heuristic for windows that stay in managed state: they float and behave correctly, so only the ring is wrong, and the fix belongs where the ring is drawn. This is deliberately narrower than excluding all focused floats — a user who floats a standard window still wants its ring; a panel does not. The classification is captured at track time (ManagedWindow.isTransientOverlay), so the pure borderSpecs decision stays AX-free, and it clears the moment detection self-heals a window back to tiled — the flag can never outlive the float state it depends on (overlay ⟹ floating).

The same class is also never granted a space’s focus when it appears (#671). KiwiDesk used to hand the focused slot to every window it saw created, so a popup that surfaces as an AX window — a Telegram context menu — became space.focused on arrival, and its dismissal therefore read as the focused window closing: the fallback handoff fired a kAXRaiseAction that re-activates an app and, under mouse-follows-focus, warped the pointer off what had just been clicked. In a focus-driven layout the grant also panned the space toward the popup. A window nobody asked to focus should not collect the consequences of being focused.

This stops at the grant deliberately, and does not extend to the slot: a window in this class that macOS genuinely focuses still lands in it through the focus report a moment later. That is what the long-lived members need — a layer-0 dialog or panel carries the same flag, and the paragraph above is precisely the ruling that those windows behave correctly and want their focus, with only the ring wrong. Denying them the slot outright would put every focused command on the window behind the one being typed in. The signal is the structural overlay flag and not floating-ness, exactly as for the ring: a window the user floated through float_rules is an ordinary window and takes focus like one when it spawns.

The Space Bar draws none of them either (#683), and for the ring’s reason rather than a new one: a popup layer is not one of “the app’s windows” in the user’s model, and a right-click that adds a glyph — plus two more for a submenu — is describing a gesture rather than the space. The filter therefore sits where the bar’s members are read, not in tracking or the ignore gate, and it runs before the same-app grouping and the glyph cap (#376), so an overlay can neither split a run nor reserve a capped slot the bar then draws nothing in. The App Bar needs no such filter: it builds from the tiled members, which a structural float has already left.

The launcher subset of that class — an accessory app’s raised-layer command bar (Spotlight, Raycast, Alfred) — graduated from draw-time suppression to the built-in ignore gate (#448): #300 kept those bars managed because only the ring was wrong, but multi-monitor QA (#446) showed a managed bar is also space-pinned — tiled, stashed, and dragged across space switches. They are now never tracked at all (accessory policy and raised layer, plus a layer-scoped bundle belt for a dock-icon Raycast, alongside Ghostty’s quick terminal #21). The draw-time heuristic remains for the structural floats that stay managed: panel-subrole windows of regular apps and accessory apps’ layer-0 windows.

The optional glow (#358) — a soft blurred colored bloom around the ring, the JankyBorders COLOR_STYLE_GLOW look — is a global bool (border.glow, default OFF) with two deliberate scope choices. It rides the focused ring only, never the unfocused set: a bloom on every dim ring would undercut the one it exists to make pop, and unfocused_color is tuned to be present-without-competing, the opposite intent. And its outward extent is kept out of outwardReach, so border.fit_gaps still sizes gaps to the crisp stroke and the soft bloom is allowed to bleed into the gap — the overlay frame grows by the blur so the halo isn’t clipped, but the gap math stays simple. The blur scales with the ring width (clamped; BorderGeometryTests pins the formula’s calibration points — cite the test, don’t restate the numbers): #533 device QA showed a fixed blur swamps a hairline ring and vanishes against a thick one. The formula is the 0 = automatic default of border.glow_size (#551, owner-requested): an explicit size overrides it, clamped only at a renderable ceiling — the GUI curates a tighter slider band, Lua stays open — resolved once in BorderStyle.resolvedGlowBlur before any geometry, so the pipeline still carries a single finished number. A glow ring also renders on the AppKit backend (BorderOverlay.ensureBackend), swapping back to SkyLight when glow turns off: the WindowServer-backed SkyLight context drops any CGContextSetShadowWithColor hue to the default black-at-low-alpha — a grey smear with a clipped hard edge (#533, device-confirmed with the colour rebuilt in sRGB and GenericRGB both, and with the bloom pre-rendered to a bitmap and blitted) — and painted-falloff substitutes banded on device (the same contour lines as the shelved first attempt, which shadowed the thin stroke directly). The CAShapeLayer double shadow (a full-radius pass plus a half-radius boost, summing toward the full glow colour at the ring edge) is the one renderer that blooms correctly; the cost is that a glow ring under draw_order: "front" degrades to behind-the-window ordering. Default OFF is native-first — a fresh install reads as a crisp flat ring, glow is opt-in flourish.

A native-fullscreen (green-button) window is suppressed by the same draw-time mechanism: it stays a member of its home space (macOS moves it off the Desktop without a destroy), but it fills the display, so a ring would peek out only at the rounded corners — jankyborders skips fullscreen windows for the same reason. The verdict (ManagedWindow.isFullscreen) is snapshotted from AXFullScreen at track time and refreshed change-only on reconcile, keeping AX out of the border path; it is orthogonal to floating, so float mutations never touch it.

The same flag exempts the window from the whole tiled working set while it is away (#670): it keeps its slot in space.windows (fullscreen is not a destroy), but both tiled-member derivations drop it, so no layout pass computes a frame for it, no navigation step lands on it, no z-order raise targets it, the inactive-space stash never parks it, and a resize aimed at it is refused rather than routed into a layout (#1298) — an AX poke at a window macOS moved off the Desktop into a Mission Control slot of its own either fights the fullscreen app or raises it under the user without intent. Exiting fullscreen is a membership change like a float flip, so it retiles and the window re-enters its kept slot. While a fullscreen app holds the screen KiwiDesk stands down: the bar panels follow the user everywhere by construction (.canJoinAllSpaces + .fullScreenAuxiliary), so both bars gate per display on whether a Desktop is showing, and the Desktop-switch settle skips its retile and refocus — the raise would yank the Desktop’s focused window up behind the fullscreen app. That verdict is NativeSpaces.isUser, never the nil Mission Control number, which is indistinguishable from “SkyLight unavailable” — and unavailable must keep the single-Desktop fallback fully alive, so a lookup miss always counts as a Desktop.

“Without a destroy” is AppKit’s transition, not every app’s (#1272). Zen — Firefox behind it — orders the real window out for the transition’s beat on both ends: for about half a second it is on neither the app’s Accessibility window list nor the on-screen census, while the compositor already hosts it on the fullscreen Space (entering) or back on the Desktop (leaving). Read as a close, that beat cost the window its slot and handed the focus to a neighbor on every exit. So the reconcile sweep’s removal-distrust gate has a fullscreen arm beside its carried one: a vanish of a window last read in fullscreen, or one the compositor hosts on a fullscreen Space, is refused for the same bounded recheck budget, and the window comes back through the membership change above rather than as a new arrival. “Still hosted” alone is deliberately not the signal — a closed window lingers on its Desktop’s Space for a while, so only the fullscreen half of the reading tells the transition from a close; the residue, a window closed while fullscreen dropping a budget late, is in Accepted Limitations.

The ring’s rendering backend is opportunistic, not architectural (#285): when the complete runtime-linked SkyLight drawing and event surface resolves, an SLS window follows WindowServer move/resize/order events directly. One carve-out: the glow ring mandates the public AppKit renderer for correctness (#533, see the glow entry above) — bending the doctrine in the safe direction, toward the mandatory public fallback, never onto the private path. Drawing and tracking degrade independently: a failed raw-window operation replays the ring through the public AppKit panel without discarding a healthy WindowServer event stream. Direct mouse drags use one movement authority: WindowServer bounds whenever its event surface is active, otherwise the stable AX/AppKit fallback. No path projects a border from cursor motion, so macOS edge/corner dwell holds the ring and target together. No private symbol is linked at launch, and the optimization never changes SIP requirements or the layout/state model.

Two vocabularies, one split (#185 review, 2026-07-12): navigation (focus, window swap) is spatial and layout-agnostic — left/right/up/down everywhere, per the table above — while the two track sequence verbs (move_to_track, track.swap) speak prev/next. They operate on the 1D track sequence, not on geometry: prev = lower array index (the column to the left / the row above), next = higher (right / below). This kills the per-axis inert direction pair (with compass arguments, two of four bindable rows were always dead keys) and a binding survives an axis flip. Do not extend prev/next to focus — that would fork the navigation model for one layout — and do not add compass aliases to the sequence verbs.

Track is guided by copy, not gated (#188, 2026-07-12): an earlier design put the track layout’s multi-window surfaces (the cap, new_window, move_to_track / track.swap and their shortcuts) behind a global set_track_advanced switch, default off, with the shortcut rows inert and hidden until it flipped (#181). That was reversed: every track surface is always visible and always works. Newcomers are oriented with copy instead — the header caption on Layout Defaults ▸ Track marks it a more advanced layout, and the shortcut rows say which layout they belong to. A blocking flag bought guidance at the cost of a whole machinery — inert-but-stored keybindings, a resolution clamp, silent-steal conflict handling — and made unbound track rows in another layout read as broken rather than simply irrelevant. Copy carries the same message with none of that. The obligation the copy carries is not “Track has a caption” — since #678 turn 10 every layout card does — but that Track’s own says what the others’ do not: that this layout is the harder one. Reword it and the guidance goes with it.

Amended 2026-09-14 (#1440): the shortcut half of that copy moved from a subheader caption (“only relevant if you’re using the track layout”) to the rows’ own drawer — Shortcuts ▸ Move windows ▸ Move windows in the track layout, the #1125 door shape, with a ? saying what previous and next mean in a track. The substance of this ruling holds: nothing is gated, no flag is stored, the rows work whether the drawer is open or shut, and a user with a Track space or a Track binding meets it open. What changed is where the sentence lives; the title now carries it, so a caption restating it inside the drawer was struck (owner ruling 2026-09-14).

The overflow track is read-time, not stored (#192, 2026-07-12): when there are more tracks than the space’s normal capacity, the fitting prefix tiles and the surplus merges into one far-edge overflow track. Normal capacity is one below the Track limit N when Auto track limit is off (so a limit of N shows N tracks on screen, the last of them the overflow track — trackCap IS the limit, and a new own_track window past the normal tracks opens the overflow track rather than joining), or how many fit at min_window_size when automatic is on. Geometry always caps the total: if capacity + 1 columns can’t hold the minimum, the fit count (TrackLayout.fitCap) reduces the columns at layout time, folded through the existing counts(cap:) primitive — so the overflow track moves on its own as windows are added or the display changes; nothing is written into the window array or the break markers. Spawn placement stays geometry-free (the flat-array / pure-layout invariant, AGENTS.md §1/§5): a window lands by new_window / new_window_position and simply falls into the overflow track’s slice at render time. overflow_style shapes only that overflow track (default cascade_all); every normal track’s own overflow is always cascade_overflow. An earlier “overflow-aware spawn” idea — shifting windows into a new track at spawn based on available space — was rejected here for putting geometry into state (it would make spawn outcomes monitor-dependent and non-deterministic). This was deliberately revisited for the focused_track default — see the next entry.

The Track limit counts the overflow track (#1354, owner ruling 2026-09-09). [Principle] The number a user types is the number of tracks they see: a limit of 3 shows three tracks, the last of them the overflow. The value used to count NORMAL tracks with the overflow beside them, so a typed 3 drew four columns — and the user counts what is on screen, not what the layout calls normal; a control whose number is one off from the picture reads as a control that does not work. Renaming the setting to “normal tracks” was rejected for the same reason: it would have made the label agree with the arithmetic instead of the eye. The floor is 2, because a limit of 1 would be the overflow track alone with everything folded into it — no track layout at all — so the setters refuse below it, the steppers start at it, and a stored 1 is lifted onto it. Because this changes what a STORED limit means, it crosses with a one-shot migration that adds one to every stored value, global and per-Space override alike, in a profile and in a backup’s inline profiles (§5: a stored value needs a crossing, never a lenient decoder); the default moves from 2 to 3 for the same reason, so a fresh seed draws the picture the old one drew. TrackLimitMigrationTests holds the crossing, TrackCommandsTests the floor, and LayoutSchematicTrackFoldTests that the preview’s arithmetic follows the engine’s. The trade accepted: a Lua script spelling track.set_limit(2) is outside every crossing by charter, and where a renamed verb fails loudly a re-scaled number runs and draws one track fewer, with only the refused 1 to say anything.

BSP alternates by default (#1181, 2026-08-31). alternating — horizontal then vertical by depth — rather than longest_side, which cuts each region’s longer side and keeps windows square-ish. The alternation is the mental model the word “BSP” carries for the people who reach for a BSP layout, so a new user meeting longest-side placement reads it as the layout misbehaving rather than as a policy choice. A default is the product’s opinion, and this one was reading as wrong to the audience the layout is for. Both strategies stay available and only the default moved; bsp.set_strategy and the per-space override are unchanged.

The change reaches existing users, deliberately. BspParams.encode writes strategy unconditionally, so every GUI-saved config and profile already pins its own value and is untouched — what moves is fresh installs and any config that never set the key. That is a behaviour change on update and it earns its own release-notes line rather than arriving silently.

Fill-then-spill is the track default; the spawn-geometry ban is relaxed for it (#437, 2026-07-23): focused_track — now the default (own_track demoted to the ultrawide “one app per column” opt-in) — fills the focused track and, when it can’t fit another window at min_window_size, spills the next window into a new track beside it (focus follows, so the recursion needs no special-casing). The unbounded within-track pile the old focused_track produced was never a chosen feature — it was the overflow fallback moonlighting as primary behavior. Getting the shelf-like “fill the column you’re at first” feel requires the geometry #192 kept out of spawn: the spill boundary is “how many fit at min_window_size,” a display-dependent count. So the ban is relaxed for this one decision, with the cost #192 named accepted: spawn outcomes are monitor-dependent (a set of windows packs into fewer tracks on a larger display, and moving to a bigger display does not un-spill an already-spilled window). The containment that keeps it honest: the geometry is computed only where it already lives (TilingEngine.trackCapacity, the same fitCap the render piles by) and mirrored into the pure state core as a plain per-space Int (StateCoordinator.trackCapacities, like trackParams), so Space.insertIntoTrack stays a pure function of the flat array plus that number — no LayoutContext reaches the state layer. The pile survives only as the no-alternative fallback (a fixed limit cap with no room, or a move_to_space traveler an explicit placement mustn’t relocate), so it never contradicts the spill. Entering track mode seeds the same way: focused_track packs the existing windows into filled tracks (TrackLayout.fillSeed), own_track gives each its own — the seed mirrors what incoming windows would do. Navigation and the overflow-pile classification are unchanged (the pile is still the array-order Track model’s fallback), so the table above keeps its Track row as-is.

Raise-echo revert: state-only, and a click is provenance

Section titled “Raise-echo revert: state-only, and a click is provenance”

[Rationale]

A z-order raise couples with app activation, so every window a restore raises emits a focus report carrying no self-raise provenance (#152). KiwiDesk stamps the raised windows and reverts the first report from a stamped window back to the real focus (zOrderRaiseEchoes, #418/#425). Two rulings shape that revert (#687):

The revert moves state only, never OS focus. During a sequence, macOS key focus genuinely churns window by window as each raise’s activation lands; the one owner of putting it back is the sequence’s closing re-assert — the generation-guarded completion every sequence hands to performZOrderSequence (raiseSequentially(thenFocus:) for pile restores, raiseFloatsAndSticky for float raises) — so a stale sequence cannot steal focus back. Re-asserting inside the revert instead — once per echo — would issue a loud raise mid-drain for every echo that trails in, fighting the very ordering the drain is verifying and re-activating the focused app once per pile member. The divergence a state-only revert leaves (state on the intended focus, OS still on the echoed window) is transient by construction: the closing re-assert ends it, and an echo arriving after that re-assert finds OS focus already restored, so reverting state alone is exactly right. The one case where the divergence persisted was a wrongly-reverted click — closed by the second ruling, not by re-asserting.

A click that reached the reported window escapes the revert. A genuine click on a stamped window is shaped exactly like the raise echo, so it was consumed: keystrokes followed the click (macOS focused it) while ring and pan stayed behind — the first-click-does-nothing bug. A restore’s echoes come from windows the user did not click, so a fresh click that reached the reported window is provenance no echo can forge. “Reached” is deliberately stricter than “landed inside its frame”: edge-pile frames overlap, so a slow pile-mate’s late echo can contain the click point too, and honoring it would pan the row onto a window the user never clicked. Which window a press reached is therefore resolved at press time (one WindowServer stacking read per left press, ~0.4 ms — the #684 measurement): the frontmost managed window containing the point is, at that instant, exactly the window the press lands in. Resolving at echo time instead would read a stacking the drain may have churned since — a quiet raise cannot beat another app’s key window (measured for #684), but raising a same-app sibling makes it the app’s new key window, so a stamped sibling could climb above the clicked window and forge the escape — against frames a retile may have moved. Skipping untracked windows is a known narrowness: a click on a non-click-through ignored window overlapping a stamped one can still resolve to the window beneath, failing toward honoring a focus report, never toward eating one. The escaped report keeps its stamp — in fact no echo ever consumes one: stamps expire by age alone, because lazy apps re-report a raised window a second time hundreds of ms after the first echo, and a consumed stamp let that duplicate through as deliberate focus (ring, pan and pointer snapped back to the pile-mate — the #689 device trace). The deliberate-refocus case consumption used to protect has real discriminators now: clicks escape on provenance, commands route through the self-raise path, and only a clickless app-driven or cmd-tab focus inside the ~1 s window is eaten — strictly better than the pre-#418 permanent poisoning.

Three corollaries, the first and third from the #687 device QA, the second from #887’s. Every echo ledger is age-bounded, the focus raise’s selfRaiseStamps included: raising an already-key window — the restore’s closing re-assert does exactly that — emits no echo at all, so an unbounded entry sat unconsumed forever and classified the user’s next click on that window as KiwiDesk’s own raise echo; a stamp counts as an echo only while it is recent, and even a fresh one stands down for click provenance. A self-raise stamp is never consumed by its echo — the scrolling snap-back (#887, device trace 2026-08-31): every fast navigate step made the departed app report its window’s focus twice, the duplicate ~150 ms after the first and after the user’s next step, and a stamp consumed by the first echo left that duplicate honored as deliberate focus — ring, pan and pointer snapping back to the window just left. The stamp expires by age, exactly as the z-order ledger’s does, and with nothing consumed, “raised by us?” is a question of order rather than presence: a same-app sibling raised after the reported window distrusts its report and one raised before does not, because a step A→B inside the window leaves both stamps fresh and a presence test would eat B’s own echo; likewise a self-raise vetoes the z-order revert only when it is newer than the z-order stamp — the #431 keyboard focus onto a window a restore stamped earlier — because an older self-raise beside a fresh z-order stamp is the restore’s own echo, and a freshness veto let that restore steal the user’s next step back. A press a bar absorbed resolves no window: the bar is KiwiDesk’s own overlay, absent from state, and resolving through it handed the window beneath a provenance it never earned — which would also let a bar click forge the escape for a stamped window under the strip. The painted strips (shownStrips, the #242 authority) are the mask.

A placement bounce is the app’s answer, not the user’s (#1161)

Section titled “A placement bounce is the app’s answer, not the user’s (#1161)”

[Rationale]

The Android Emulator’s Qt shell answers being placed past a screen edge by focusing itself. Measured 2026-09-05, 3 of 3 trials with the pan as the only input: a scrolling pan asks it to sit at x = 1665 on a 1728-wide screen, and 0.8–1.5 s later its window reports focus and the app activates; a Space switch that parks it at the stash corner does the same 1 s later, the window refusing the corner. Never once did it happen on the pan with the window floating, where the pan does not move it. That focus report has the same shape as a cmd-tab: clickless, no self-raise stamp, a different window than the one the user just reached. Honoring it panned the row back to the emulator, warped the pointer onto it, and — from a hidden Space — flew the user back to the Space they had just left.

The distrust keys on placement, not on activation: the TilingEngine.placements ledger records where KiwiDesk last put each window, stamped at the two frame leaves the retile, the stash and the App-level placers share, age-bounded like every echo ledger and never consumed. It is a further record of “where we put it” beside the applier’s instant target, the animation’s target and the learner’s asks, kept because none of them lives long enough — the bounce lands after every one has retired.

The placements ledger is read by geometry rather than by an ordering of stamps — there is no second placement stamp to rank against — and the verdict is a clickless focus report within the placement window, arriving after focus moved on, for a window in the ledger. In the active scrolling Space the live entry is the whole verdict, and that took four device sittings to accept: the emulator complied within 9 pt of a pan into the void and bounced regardless, so “the window is not where we put it” discriminated nothing; it bounced after a pan that left it on-screen, so “past the edge” discriminated nothing; it bounced after a focus command stepped off it with its size bound learned and no frame asked of it, so “the app refused the size it was asked” discriminated nothing and read as a bare mismatch it also matched every window whose resize echo had not landed (the #1049 lesson, one subsystem over); and it bounced after a click onto a neighbour panned the row. Every discriminator narrower than “we moved it, or stepped off it, inside the window” honored a bounce on the device, and each honored bounce cost more than a pan back: it left KiwiDesk’s focus anchor on a window the OS did not front, the split in which the foreground preflight refuses every focus shortcut until the app reports again. So the row prices one trade instead of four: a cmd-tab onto a scrolling window a pan just moved is bounced for the window. The focus command records the window it left in the same ledger — an entry a later pan’s placement carries rather than erases — so the displacement shares the ledger’s prune, renewal bound, forget and rekey instead of earning a ledger of its own. Anywhere else — the corner a hidden Space’s windows are parked in, monocle’s park, a scrolling Space that is not the active one — a clickless focus is how a user reaches an off-screen window, so the placement must lie past the edge and the window must have refused it by position, which the emulator does at the stash corner and a window that went where it was parked does not.

The discriminator is forgeable by construction: a cmd-tab onto a scrolling window a pan moved inside the placement window, or back onto the one the user stepped off inside it, is bounced too. A distrust renews the placement, because the emulator retries every half second or so and its third retry landed past a window that started at the pan — but through the ledger’s own renew door, which extends the window only while the placement itself is younger than it. The forgeability is why the chain must end: the report that renews is one the predicate cannot tell from a cmd-tab, so an unbounded renewal let a user’s own repeated attempts extend their lockout forever, and the documented escape (“try again after two seconds”) became one only a user who stops trying could reach. Bounded at the placement, an app that keeps reacting is still bounced for up to twice the window, and a keyboard user is honored by then. That is the ruled trade; a click is always honored.

The #465 sibling distrust’s carve-out for a visible same-display sibling is narrowed by this: such a sibling placed into the scrolling void is bounced like any other window.

An own act is never a bounce (#1281). The trade above is priced because the predicate cannot tell the emulator’s self-focus from a cmd-tab: both are foreign reports of unknown provenance. KiwiDesk’s own Settings window is never of unknown provenance, in either of the two ways it comes to the front, and both were bounced inside the placement window for the same reason — the report reached the predicate with nothing on it that a foreign report would lack. A CLICK on it: a click is the provenance no re-report can forge, which is what makes “a click is always honored” a rule rather than a hope, and it had held for every window but one, because the stamp that carries it was written only from the global monitor’s fan-out and a global monitor never sees a press routed to our own windows. The press fan-out now hears both arms and carries the press’s origin, so a consumer decides its own stand-down (input-and-animation.md owns how). A RAISE the GUI starts — the menu bar, the App menu (⌘,), the open_settings chord — is our own deliberate act, so it takes the construction the keyboard verbs already have: the GUI’s raise branch calls KiwiCore.focusOwnWindow before forceFront, and that door issues the focus command first, so state focus is set before the report and it arrives with intended == id, which the distrust never reads. Neither answer is an exemption in the predicate, which would be the per-process carve-out the own-window rules refuse (#678 item 18); both give the report the provenance it was owed. The door lives beside the arm it mirrors and takes the arm’s own gate — the window’s Space is the active one — because that is the one arm a compliant own window can meet; parked elsewhere it goes where it was placed, and a clickless report is how it is reached. It is wider than the arm by the mode on purpose: the command is the right raise on any shown Space, so an arm that widens later is covered already.

The door owes a CLOSED own window the same command (#1380). An NSWindow keeps its number across a close and a re-show, so the Settings window a user closed and reopened comes back to Core as a RETURN — rememberedSpaces still names its Space — and a returning window never steals the focus that stands (#636): the fold sets no intent, and the report reaches the predicate clickless inside the placement window of the arrival’s own retile, which in the active scrolling Space is the whole verdict. Refusing the untracked number, as the door first did, left that report with nothing on it a foreign one would lack. So the door records the number as a debt — the third FollowFocusIntent instance beside the follow’s (#1007) and the return’s (#1207) — drained on the same .windowCreated arm and paid with the same focus command the tracked arm issues once the arrival has given the window an id, judged there on the Space it landed in, the one thing the door could not read, and stood down where the fold or a sibling debt already set the focus. Only a scrolling Space shows the defect: anywhere else the bounce needs an origin the window refused, which a compliant own window never gives.

State stays on the intended window and it is re-asserted with a direct, unstamped raise — the #465 sibling-distrust shape. The state-only ruling of the raise-echo revert above does not reach here, and the reason is what makes it a ruling rather than a habit: there a drain is in flight and its closing re-assert is the one owner of putting OS focus back, so reverting state alone is transient by construction. A placement bounce has no sequence and no closing re-assert — a state-only revert would leave keystrokes on the app’s window and the ring on the user’s, permanently — so this one raises; the raise moves nothing, so it provokes no second bounce, and the ledger is never consumed, so the app’s repeat is bounced the same way. The distrust runs below the z-order revert on purpose: a restore’s echo keeps the state-only revert its sequence owns.

An activation gate — “an app that activates with no input is not focus” — is the wrong shape: cmd-tab is also clickless activation, KiwiDesk has no keyboard provenance for system chords, and the measurement shows the trigger is our own frame-set, not activation in general.

A raise across Desktops is a Desktop switch in disguise (#1345)

Section titled “A raise across Desktops is a Desktop switch in disguise (#1345)”

[Rationale]

Swiping to a Desktop that holds a second window of an app whose first window stays behind bounced the user straight back. Measured 2026-09-08 on macOS 26.6.2 with a compositor probe (CGWindowListCopyWindowInfo on-screen, 10 Hz), KiwiDesk’s log and a state sampler side by side: with KiwiDesk quit the swipe arrived and stayed for 24 s; with KiwiDesk running every swipe bounced within 0.4–3.6 s, and the verb-driven focus_desktop never did. The decisive trace lines up to the millisecond: the Finder window the user arrived at reported its focus, the #1161 placement-bounce distrust rejected that report — KiwiDesk had just placed the window, and the report was clickless — and re-asserted the previous focus, a Claude window on the Desktop the user had just left. Raising it activated Claude, and macOS switched Desktops to show it. The close-return raise then kept the ping-pong going the same way: each of its successor picks sat on the other Desktop, and its isListed guard (the older AX net, retired by this change) passed because Finder lists both Desktops’ windows for a beat after a switch, which is exactly when the pick lands.

Why the departed window was still there to be re-asserted: the Desktop switch is not a close, and an Electron app’s destroy notification for the window that left the view arrives seconds after the swipe. Until then the window is in state, it is the focusBefore every distrust arm reverts to, and it is NOT yet in the away ledger — so neither state nor the ledger can answer “is this raise safe”. Only the compositor can, and it does in one read: hosted on a Space no display shows.

The cure is one gate at the raise rather than a clause per arm, because the class repeated — the placement arm, the close-return successor, and the #465 sibling and #958 return arms carry the same direct re-assert shape. raiseWindow refuses ahead of every focus path and logs it, so a bounce that still occurs names its arm instead of staying silent; the three re-asserts stand down through one predicate and honor the report, since a state-only revert would split state focus from real key focus (#952). The trade: a close-return successor that lives on another Desktop is not raised, and focus stays wherever macOS put it until the next report — which is the Desktop the user is looking at, so the next report is the right one.

The gate is not the whole rule, because the return swipe bounces without any raise crossing Desktops (measured 2026-09-08 on the gated build): swiping back, the Desktop 2 Finder window’s departure read as a close, the close-return picked the same app’s Downloads window on the Desktop being shown and raised it — legally, it was shown — stealing the focus macOS had just handed back to the window the user left; and when macOS then reported that window, the placement distrust bounced the report, since the arrival retile had just placed it and the report was clickless. Two arms follow from “a swipe is not a close”. A window that left with its Desktop (vanished, and not a move verb’s own latched departure, which IS a hand-off the user asked for) stands the close-return raise down, the way a hide does (#913): macOS picks the focus on the Desktop it shows. And a report for the Desktop’s remembered focus coming back — the #1207 memory already records, at every honored report, which window each Desktop last had focused — is macOS restoring it, not an app answering a placement, so the distrust stands down on it. That read honors an app whose window WAS the remembered focus and bounces after a return, which is the window macOS restored regardless; a bounce racing the echo of a step off it is the residue, priced below the emulator’s measured 0.8–1.5 s and recorded in Accepted limitations.

Which compositor read matters, measured the same afternoon on the first build of the gate: it read the managed display’s “current Space” and lost the race. At 13:56:57.46 the swipe’s departures were folding, the on-screen window list (the probe beside the log) had already dropped every Desktop 1 window, and the current-Space reading still named Desktop 1 — so a Zen window on the Desktop being left read as shown, the placement distrust re-asserted it, and the user bounced. That is the #1023 finding one level down: the current-Space reading tracks the pointer and the draw list is the ground truth. The gate therefore reads CGWindowList’s own kCGWindowIsOnscreen for the one window, and nothing that could lag it. The same measurement moved the gate from the AX call to the verb: focusWindow had already written state focus, noted the displacement and warped the pointer before raiseWindow refused, which is a state/key split of its own — so the verb is refused whole, ahead of all of it.

Two reads the draw list had to survive, both measured on the final build. A window KiwiDesk stashes off-bounds on the SAME Desktop — the peek corner, a hidden Space’s park — still reads on screen (an Antigravity window parked at x = 1727 on a 1728-wide screen read kCGWindowIsOnscreen = true, and the Space switch onto it focused it without a refusal), so every virtual Space switch keeps its raise. And the #1207 payment at an arrival raises through the same gate: on the owner’s round trips “focus paid to” was followed by the honored report, never by a refusal, so the AX create that pays it arrives after the draw list lists the window.

The settle’s refocus is the third arm, and the gate cannot stand in for it (#1364). A swipe to an EMPTY Desktop made macOS activate Finder — the desktop itself — and Finder’s window on the Desktop just left was re-listed within the second, adopted back into its space’s vacancy, and re-asserted 1.2 s after the swipe by the settle’s refocus: the pull-back, measured on device 2026-09-10. The gate let it through: the on-screen flag it reads was true for that window, and a compositor probe on 2026-09-13 showed why that flag cannot be trusted here — during a three-finger gesture it reads true for every window of the neighbouring Desktops from the first movement until the switch registers (the #1410 paragraph below carries the re-read). So the settle refuses a focus the switch itself removed, and reads no flag to do it: every departure departedWithDesktop files is stamped, and the settle asks whether the focused window’s stamp belongs to the switch it is settling — no earlier than the switch grace before it, since an app’s own destroy beats the notification, and inside an age bound, since a slow app’s destroy trails the swipe by seconds and a stale stamp must not stand down a later switch’s refocus. A window found so is left where macOS put the focus, with a log line naming it. The residue is the grace itself: a departure filed inside the 0.75 s before a switch it did not belong to — a swipe back while a slow app’s destroy is still trailing, a drag’s vanish just ahead of a swipe — reads as that switch’s, and its window’s refocus stands down once; nothing filed at the departure can say which switch it belongs to, since the destroy arrives before the switch does. The trade is the same one the close-return arm already made — macOS picks the focus on the Desktop it shows — and the two cases it must not touch are held as controls beside it: a window that stayed through the switch is re-asserted exactly as before, and a Desktop return’s remembered focus is #1207’s payment, which stands this refocus down before the new arm is reached. The other half of the issue — a swipe on one display removing a window on a display whose Desktop did not change — did not reproduce on 2026-09-13 with two displays and an event-stream trace of every removal; what remains open lives on #1364, not here.

The gate asks two compositor reads, and either refuses (#1410). The flag alone had a blind spot the probe measured on 2026-09-13, every gesture of the sitting: from a three-finger swipe’s first movement until the switch registered, 1.0–1.5 s later, kCGWindowIsOnscreen read true for every window of the swiped display’s neighbouring Desktops — the ones being composited for the gesture, on no display’s current Space — and read false for the Desktop just left the moment the switch landed. A raise gated on the flag inside that second passes for a window on a Desktop nobody shows, and macOS then switches to show it; the Sept 10 settle refocus (crosses=false at +1.2 s) is the measured instance, and the #1364 arm was cut to read no flag for exactly this reason. The current-Space reading fails the other way, as the paragraph above records: through a switch it names the new Desktop while the draw list still composites the old one (#1023). Neither read lies in both directions, so the gate takes both — the flag, and whether the Space the compositor hosts the window on is one some display currently shows — and refuses when either says unshown, while a read that cannot answer abstains: without SkyLight the flag decides alone, as it did. The trade is a raise refused where one read lags into “unshown” for a window that is in fact shown, at the switch itself; focus then stays where macOS put it until the next report, the price #1345 already set. Two clauses the second read carries: an all-Desktops window (the Dock’s “All Desktops”, a canJoinAllSpaces panel) is hosted on every Space, so ANY shown host counts, or every focus of such a window would be refused; and the read costs ~0.15 ms per raise (the topology copy 0.11 ms, the per-window list 0.03 ms, measured 2026-09-13), so it is live rather than cached from the switch handler, whose stamp is the notification’s timing and not the compositor’s.

A focus report is only as good as the activation behind it (#1322)

Section titled “A focus report is only as good as the activation behind it (#1322)”

[Rationale]

kAXFocusedWindowChanged is an app-level notification: it says which of ITS windows an app now calls focused, not that the app holds the system focus. KiwiDesk read it as the latter, and a non-activating panel showed the difference. The Claude desktop app’s overlay is an AXSystemDialog that takes key without activating its app; while it is up the app’s focused window is empty, and on close it flips back to the main window — with NSWorkspace.frontmostApplication staying on Zen or Telegram through every cycle (measured 2026-09-07). KiwiDesk honored the flip, moved its anchor onto Claude’s window, and the command preflight (#292) then refused every focus chord as “frontmost pid is another app” until a click re-synced the two — the preflight was right, three seconds after the report should have been.

The ruling: the accessibility channel reports a focus only from the app macOS activated last. The activation channel keeps lastActivePid from didActivateApplicationNotification; before the first activation the frontmost reading stands in; with neither, the report stands — fails open by design, since starving focus until the first app switch is the worse failure. A report that fails the gate is dropped, not held for re-check: if the app does activate, appActivated reports its focused window itself (the path that already exists because clicking another app’s window only activates the app), so the ordering race on a genuine cmd-tab — AX report before activation notification — resolves by construction. The gate sits after the untracked classification, so an ignored panel of an inactive app still arms #244’s dismissal distrust. The gate lives in the producer, not beside handleWindowFocused’s six arms: those judge whether to honor a system-focus report, this asks a fact about the CHANNEL, and only the event loop can promise the drop is safe because it owns the emitter that re-reports on activation. The trade, stated: that re-report is appActivated’s own focused-window read — the lazy-app read #465 distrusts — so when a true AX report preceded the activation notification and was dropped, the activation may name the app’s OLD window; #465 then holds the intended one and the outcome converges. Neither #465 (the raise activates the app) nor #244 (Ghostty is frontmost when its panel dismisses) is starved, since in both the app did activate.

An ignored panel’s dismissal is a race; provenance ends it

Section titled “An ignored panel’s dismissal is a race; provenance ends it”

[Rationale]

An auto-ignored panel — Ghostty’s quick terminal, the #448 launcher class — dismisses itself on focus-out, and its app then re-reports its main window as focused. That report is a visibility artifact, not a user intention, and the dismiss distrust (#21/#244) exists to consume it: honoring it moves focus, ring, warp and the scrolling pan onto a window nobody chose, possibly on another space.

The distrust originally disarmed on the first focus report for any OTHER app, reading it as “the panel’s app resigned frontmost”. That treats arrival ORDER as ground truth, and the order is a race KiwiDesk does not control: the user’s click on window B and the panel app’s stale re-report come from two apps’ AX streams, and live capture (#951) measured the stale re-report landing 125–200 ms AFTER the click that should have settled the question — so disarm-then-honor handed focus back to the panel’s app at the exact moment the user clicked away from it.

So the flags survive a short dismissal grace instead, and what ends the distrust early is provenance, never order: a report carrying click provenance (#687’s press-time resolution) is the user’s own choice — it escapes and clears every flag — while a clickless same-app re-report inside the grace is consumed exactly as before. The accepted trade has its accepted-limitations row: a genuine clickless focus of the panel app’s main window (cmd-tab) inside the grace, right after focusing elsewhere, is eaten once — the same single-shot, recoverable class as the echo-window trades above, and strictly narrower than the race it closes. The grace length is derived from the measured race margin and argued at its constant (KiwiCore+IgnoredPanel.swift); IgnoredPanelGraceTests pins the state machine, the escape and the expiry. The same grace also covers KiwiDesk’s own summon chrome: closing the ⌃⌥K panel blip-keys another own window, whose clickless AX re-report trails the close’s activation yield (#952 — the yield itself is a Shortcuts-section ruling). (#951)

A wake restore pays the focus it adopts; a launch never steals one

Section titled “A wake restore pays the focus it adopts; a launch never steals one”

[Principle]

A state snapshot carries each space’s focused window, and a restore adopts it. But state is only half of what “focused” means: macOS keeps its own key app, and the command preflight compares the two before any implicit-focused shortcut runs (#292). A restore that stamps state and performs nothing leaves them diverged, so every shortcut fails “no managed window is currently focused” until the first click — which is exactly what the wake/unlock restore did (#1130): the arrangement came back, and the window the user went to rest in did not.

So the wake leg performs the focus it adopted — raise plus app activation, the same act a focus command pays. It is #1007’s principle extended one leg over: an operation that names a window owes the user the window, never a bookkeeping entry about it, and a wake restore names the window the user was standing in when the machine went to rest. Fronting it again is restoring, not stealing — the user is at the machine, mid-return, and the feature’s whole promise is “as you left it”. Two boundaries keep the payment honest. When the remembered window is gone, the payment inverts: macOS already fronted something at unlock, so state follows the OS (the #442 frontmost seed) rather than raising a stand-in nobody chose. And the pointer is no part of it: it sits wherever the user unlocked, so the focus is paid without the mouse-follows-focus warp.

The launch and crash-relaunch legs answer the same question the other way, deliberately: they seed state only, from the OS frontmost (#442), because a starting app must never yank key focus from whatever the user is doing while it boots. The asymmetry is the ruling — unifying the legs in either direction re-breaks one of them: a state-only wake restore is #1130 again, and a performed launch focus is a focus steal.

Activation is cooperative and macOS may decline it, so the payment can silently fail to land. The escape is a one-shot heal on the preflight itself: armed at the wake payment, the first shortcut press that would otherwise fail re-seeds from the real frontmost and asks again — so the press acts on the window the user is genuinely looking at — and any honored focus event disarms it, the divergence being over. The accepted residue: after a declined activation, that first press acts on macOS’s front window rather than the remembered one, which is strictly better than a press that does nothing. WakeFocusRestoreTests pins the wake leg’s payment, the crash leg’s stand-down, the gone-window seed and the heal; WakeFocusSeamTests pins the wiring no unit fixture can see.

[Rationale]

How the layout engine answers resize, orientation, and overflow questions — settled trades, most of them consequences of the flat-array model (AGENTS.md §1/§5). Navigation and overflow-pile classification live in the table above; how a two-axis layout’s wire keys are named follows the geometric-wire rule in Settings UI patterns.

follow holds a place, not a number: a resize re-anchors the viewport (#966). A scrolling row has one slot size for every slot, so resizing one moves every slot’s position along the row. Three of the four anchors never noticed — center, start and end recompute a resting position on every call. follow is defined against the previous offset, an absolute distance along that row, and holding it across a resize meant holding a number that now pointed somewhere else: the window being resized slid toward the leading edge, reading as a scroll nobody asked for. (The freed space does not collect at either end — the row contracts around wherever the offset happens to hold it, which is the point: nobody chose that place.)

The ruling is that follow remembers where the focused window rested, not how far the row was pushed. The stored viewport value carries the slot it was measured against, and the offset math asks one question of it: is this the same focus as last time? A focus change holds the offset and pans minimally — follow’s original contract (#66), where nothing moved, so the side you came from stays open. An unchanged focus whose slot has moved holds that slot’s place on screen instead and lets the row rearrange around it.

That second arm deliberately covers more than the resize that found it: a window opening or closing ahead of the focus, and a #677 bound re-packing the row, are the same event — the row moved underneath the window the user is looking at — and a rule naming only the resize would be a special case the next cause re-opens.

A reorder is the one member of that set where the premise is false, and it is ruled OUT at the model (#1353). There the row did not move: the focus moved within a static row, by the user’s own act. The genre’s idiom — PaperWM and niri scroll the row under a moved column — argues that the frame of reference changes but the outcome does not, the pair trading places either way, and #966 had ruled the swap in on that argument. KiwiDesk does not follow it, because the eye is on the window being moved and expects IT to move: a window that holds still under swap while its neighbour jumps reads as “nothing was reordered” (device, 2026-09-09). So a reorder holds the viewport and lets the pair visibly trade places, panning only where the moved window’s new slot would leave the view — the focus-change arm, whose clamp is exactly that minimal pan.

The discriminator does not live in the layout, which the #966 entry had already established: “same window, different position” is also what a neighbour closing ahead of the focus produces, and that case must keep re-anchoring. It lives in the model, the one place that knows a reorder happened: every Space primitive that rewrites the order — swap, move, the bar drop’s reorder — RELEASES the recorded slot (Space.releaseScrollSlot), and the next pass, seeing an offset with no slot, holds it and pans into view. An ARRIVAL seats through insert and keeps the slot: it is a window opening ahead of the focus, the #966 case. The obligation that makes this hold is that the window order is written by the model and nowhere else (ScrollSlotReleaseSeamTests); ScrollSlotReleaseTests holds each primitive, and ScrollingResizeAnchorEndToEndTests the keyboard swap and the bar drop on screen.

A slot resting ON a border keeps the border, not its leading edge. The rule above says “hold the slot’s place”, and place means its leading edge — except where that edge is not what the eye is reading. A slot flush against the trailing border of the viewport, with more row hidden behind it, has to give its space back on the OPEN side: hold the leading edge there and the slot tears off the border, opening a gap the hidden neighbour then slides into, which is the one shape that reads as broken rather than merely different (device QA, 2026-08-27). This also stops two identical-looking situations answering differently — a slot that is LAST in its row already behaved this way, because the boundary clamp refuses to reveal margin past the row end, and nothing on screen distinguishes “last” from “flush with more behind it”.

Flush at BOTH borders — the slot fills the viewport — takes the leading edge, the ordinary rule. That is the one place the reading anchor is the deciding argument: the trailing rule has a claim, and it loses because holding the right edge would shift every line of text under the reader for no reason they asked for. (A slot filling the viewport has always been reachable — the layout draws min(along, …), so any over-grown slot rendered flush at both borders long before the ceiling below made the store stop there too. The both-borders arm is a case this rule had to answer regardless.)

Which border a slot rested on is decided where the offset is MEASURED, and carried with it. Deciding it later means comparing a recorded extent against whatever the viewport is by then, and a bar toggle, a gap edit or a space moving screens is enough to make that a verdict about a viewport the slot never sat in.

The clamps still win where they disagree, so near a row end the focus re-anchors only as far as the boundary allows; the row never reveals empty margin past its ends.

A scrolling slot is clamped at both ends, and only scrolling needs saying so (#966). Every interactive resize stops at a floor (#933). Scrolling also needs a ceiling, and it is the only layout that does, because it is the only one whose resize stores an absolute length: BSP and the stack master store a ratio clamped to 0.1…0.9, stack and track weights store shares bounded by the other members’ floors, and a floating window’s resize moves the frame itself, which is the drawn thing. A stored length has no such bound, so growing past the viewport inflated the store while the layout drew min(along, …) — the slot stopped changing on screen while every press still counted, and the shrink afterwards spent one press per invisible step before anything moved.

The ceiling cannot live beside the floor in the value type. A floor of 100pt is a property of a slot; an absolute-length maximum is a property of the screen, and the same config travels between them — capping a stored size against whichever display is attached would silently rewrite what the user asked for when they undock. So it belongs at the interactive-write site, where a display is in hand, which is where #933 already put the floor. A layout that later stores a length rather than a share inherits this question; one that stores a share never has it.

Two things follow from that, and both are about not destroying a choice. The ceiling is the area the layout draws, not the region it is carved from — cap at the region and the outer gaps and bar strip stay bankable, which on a vertical scroll axis is the App Bar’s own thickness. And it never reduces a stored value: setting a slot larger than the screen is a legitimate thing to have done, so a grow press refuses to go further rather than quietly rewriting it. The clamp exists to stop growth running away, not to overrule a value someone chose.

Scrolling at a screen seam: a blocked edge is a hard stop (#878). A scrolling edge is open or blocked, decided per edge from the screen arrangement. Open edges keep the #142 overhang — a scrolled-out slot hangs into the void with its edgePeek sliver visible. An edge with another screen beyond it is a wall: the slot stops flush at the border, fully on its own screen, and stacks behind the viewport — the same clamp form the top edge has always used against the top screen border (#139). Nothing is ever resized; a slot that cannot fully fit underlaps its viewport neighbor. The reason is that frames are global: past an open edge, “offscreen” is empty void, but past a seam it is the neighbor screen, and macOS cannot clip or hide another app’s window (no alpha or order-out on a foreign window, and window-server level only for the connection that owns it — the SLSSetWindowLevel entry on the SIP list above), so an overhang there renders on top of whatever the neighbor shows. Scrolling managers on Wayland (niri; PaperWM under GNOME’s Wayland session) never meet this because the compositor clips per output; on macOS the honest options are moving the body where nothing renders or stopping it at the border. The wall won over the rejected corner-park alternative (routing scrolled-out slots through the stash’s #410 corner) because it keeps the window where the scroll was taking it, needs no sliver at all — a fully on-screen frame is always achievable, so the #142 concern vanishes on blocked edges — and reuses the stacking and z-order machinery the edge pile already has (#150). The traded cost: on a blocked edge the resting peek disappears (a covered pile shows only through the inter-tile gaps, exactly as the top edge always has), and mid-scroll you watch the real window being covered or revealed instead of a sliver. Adjacency is recomputed from the connected screens on every retile (ScreenNeighbors.detect) — an input, never a cache — so a screen plugged in or out is correct from the retile the display change already triggers.

Monocle hides by z-order, and park is the opt-in for bodies the stack shows through (#881). The default stack hides the unfocused members entirely behind the focused window — perfect concealment until the focused body is itself see-through: a transparent or blurred app shows the stack through its own pixels, and since #880 a width-bound window centers with symmetric gaps the stack shows through with no transparency involved. park moves the unfocused members to the stash’s #410 corner instead — the same geometry, so the sliver trade rides its precedent: with windows on several Spaces, stashInactive piles the same slivers in that corner today, they all overlap at essentially one point, and the pile reads as one slightly denser tab (Mission Control showing parked windows at the corner is the same precedent). Only a single-Space monocle user sees anything new, which is one of the two reasons this is an option rather than the default — the other being that most users have neither transparent windows nor width-bound apps in monocle, and a default change would retune a shipped surface for all of them. The focus switch under park snaps instead of animating: the park is a hide mechanism, not motion the user asked to watch, and an animated park would turn every focus change into a corner flight where monocle’s promise is the raise-only flip. Truly hiding the windows was rejected on platform grounds: macOS offers no public API to hide another app’s window (minimize is slow and changes Dock state; moving them to another native Space is the SIP list above; ordering lower in z changes nothing — they are already behind, and a transparent body shows whatever is behind it).

A resize span is the layout region, not the display (#537). Anything that divides a delta by a span — or compares a slot against a midpoint — reads TilingEngine.layoutBounds(on:): the visible frame with the Space Bar’s strip already reserved (#293), which is the region the layout actually filled. Four resize paths read the raw display instead (the keyboard span, the BSP focus sign, the finished mouse resize, and the scrolling slot’s seed), so with the bar on — the default — every ratio nudge was understated by the strip, and the scrolling slot stored points measured against a length no layout ever used. The distinction is not a second display hook: size still enters through visibleBounds alone (#531), and this reserves the strip on top of it. The deliberate exception is a rect used as a containment box for a window the layout does not place — there is no span to divide and no midpoint to classify against, and such a window’s relationship to a bar is owned by the painted-strip clamp instead (#242), which is authoritative because it reads the bars actually drawn rather than the strips config would reserve. Which files that covers, and why each qualifies, is the allowlist in LayoutBoundsRoutingTests — the exemption list, and the only copy of it.

Interactive resizes are session-scoped per space; the config layers never move underneath them (#458). Before, a resize on a space with no authored override wrote the global ratio — coherent under the #17 layered model (“you resized the default”) but visibly wrong the moment two monitors show two no-override spaces: resizing one resized both. The two rejected alternatives: keeping as-is (documented confusion), and materializing a per-space override on first resize (silently pins the space, decouples it from Layout Defaults, and fills the #290 override editor with overrides the user never authored). Chosen: a session ratio layer on the Space (SessionRatios), the stackWeights precedent — interactive writes land there when no authored override carries the field, config stays untouched, and the layer reseeds on a real mode change or reload_config. Read precedence is authored override

session > global, and every explicit config write drops the session shadow so it always visibly applies (the #383 “visibly did nothing” rationale): a global setter (bsp.set_ratio_h, stack.set_master_ratio, scroll.set_slot_size) clears its own field everywhere, and an explicit apply — load_profile, a preset, a GUI save — clears the whole layer, riding the same forceRetile classification those applies already carry (§5); event-driven applies (monitor change, Desktop binding) keep it, so a display reconnect never eats an interactive resize. Covers the BSP split ratios, stack master ratio, and scrolling slot size — the same shape for all three, per the #458 scope note. Accepted edge: removing an override field mid-session can resurface an older session value until the next reseed.

Resize is truly 2-axis via two per-space BSP ratios; per-node ratios are rejected. resize("x") and resize("y") used to write the same scalar (one splitRatio for every BSP split, one masterRatio for stack) — the axis only scaled the step, so a “resize vertically” key visibly changed column widths. #56 gives BSP two ratios per space — ratio_h for side-by-side splits, ratio_v for stacked splits — so each axis moves its own knob, in commands and in mouse resize (a width-dominant drag edits H, a height-dominant one V). Per-node ratios were deliberately rejected: they require stable per-split identity, i.e. a container tree, which the flat-[WindowID]-array model forbids (AGENTS.md §5) — two global ratios per space is the design that fits the architecture. The Size & float catalog grows from 3 rows to 5 (Grow/Shrink × width/height + Make floating), all authored from the one shared resize.step; scrolling still resizes its slot along its own scroll axis whichever axis is passed, and monocle/grid stay explicit no-ops (floating did too until #1184). No back-compat alias for the old bsp.set_ratio / layout.bsp.ratio name (pre-release, single user). (#56)

Stack resize is focus-aware, and its zone weights are ephemeral by design. The stack layout’s resize used to always move the master/stack split toward the master, whichever window was focused. #67 makes both axes act on the focused window: the split axis (x for a left/right stack zone, y for top/bottom — #222) moves the split in the direction that grows the focused window’s zone (flipping the old always-grow-master behavior when a stack window is focused — intended), and the focused zone’s own axis grows the focused window’s share of its zone via per-window weights — a [WindowID: Double] map in Space, parallel to the flat window array (a map, not a tree: it adds no structure the flat-array guardrail forbids). The weights are session-scoped and never serialized: a WindowID is an OS window handle, unstable across app and window relaunches, so there is nothing durable to persist a weight against — persisting them would at best restore sizes to the wrong windows. They are pruned when a window leaves the space. When a weighted share drops below min_window_size, the zone falls back to the existing overflow cascade (weights apply to the fully-tiled case only), and the resize command caps weight growth at that cliff so presses past it cannot ratchet the stored weight invisibly; clamping the master ratio against min window size stays a separate issue (#44). One deliberate asymmetry: a drag along the zones’ own axis still snaps back (the mouse seam is windowless); only the keyboard/CLI resize moves weights. (#67)

The stack zone’s lineup derives from its position — no stack_orientation knob; piles always cascade downward. #222 made the stack arrangement configurable: stack_position (top/right/bottom/left) picks the split axis, and master_orientation lines up multiple masters. The stack zone deliberately has no orientation setting of its own — a left/right zone is a tall strip, so it stacks vertically; a top/bottom zone is wide, so windows sit side by side (StackPosition.stackOrientation, the single authority). Any other combination degenerates into slivers, and deriving keeps the resize axes orthogonal: the split ratio always moves on the split axis, the stack’s weights on the other. Overflow piles keep cascading downward in every arrangement (ui-designer consult, 2026-07-15): the title bar is the affordance unit (identify + drag + raise) and one pile vocabulary spans the app — a sideways pile would expose blank side slivers and read as a glitch. A wide zone’s cascade_all pile may spill over the master zone; that is the same accepted spill tall zones already do at the screen’s bottom edge, kept coherent by the managed z-order. If pile depth ever hurts, the lever is a depth cap — not a direction switch. (#222)

The master_orientation default is horizontal: side-by-side masters beside a right stack turn a raised master count into columns — the arrangement wide screens actually want — whereas a vertical master column duplicates the stack’s own shape next to it. The trade is conscious: the standard arrangement then sits inside the along-axis resize limitation above (masters’ individual shares are unreachable until the orientation is switched to vertical), and the leading-edge promotion path is the default-adjacent bug #313. (#222)

The master zone fills from the stack seam when the stack leads. (#313) StackLayout.zone lays array order from a region’s min edge, which put the boundary master (the promote/demote swap slot) at the point farthest from a leading stack — every boundary crossing teleported across the master zone. Mirrored slot order (leading stack + parallel master lineup only) is a pure render mapping: the flat array, the promote/demote swaps, and seniority stay untouched; geometric navigation follows the frames; StackSchematic mirrors via the same StackLayout.mirrorsMasterZone predicate so the preview cannot lie. Perpendicular lineups stay in natural reading order — every master already touches the seam. Boundary crossings now read identically to the trailing-stack (default) arrangement: the crossing window moves locally, survivors shift one slot. Accepted side effect: when a mirrored master zone uses cascade_overflow, its trailing pile contains the array-earliest masters instead of the latest; the pile keeps the same screen position and downward cascade either way.

The stack cascade is a last resort; extreme ratios clamp at layout time, and interactive writes cap at the visible cliff. An out-of-range master_ratio used to collapse the whole space into the OverlapStack cascade the moment a second window opened (#44). Now the layout clamps the effective ratio to the widest value keeping both zones ≥ min_window_size (SplitDomain.effectiveRatioRange, the single authority), and cascades only when two min-size zones cannot coexist at any ratio. The stored config value stays untouched — a ratio too extreme for this display is honored again on a wider one — but the interactive paths (keyboard resize("x"), mouse drag) cap their writes at the current display’s effective bound (SplitDomain.cappedRatioWrite): past it the layout clamps anyway, so a wider write would only ratchet invisibly — the same rule as the #67 vertical weight cap, and the same config-wide/interaction-capped split. #383 migrated the same principle to BSP. An extreme BSP split ratio no longer collapses the subtree into the overlap pile: the layout clamps the effective ratio per region at every recursion depth (SplitDomain.effectiveRatioRange), so a value too extreme for a deep sub-region pins that region’s neighbor to min_window_size rather than piling — the shared per-space scalar ratio needs no per-node tree for this, because the clamp runs against each region’s own span. Both BSP interactive paths (keyboard resize, mouse drag) cap their writes too (SplitDomain.cappedRatioWrite), and the pile stays reserved for a region genuinely too narrow for two min-size windows at any ratio. (#44, #383)

BSP keyboard resize is focus-aware in direction only — and some nested windows cannot grow. Accepted, by architecture. Since #122, resize infers its sign from the focused window’s slot (the same screen-midpoint side rule a mouse drag uses, shared as one authority — BspSplit.side), so “grow” grows the focused window’s side instead of always the left/top region. What it deliberately does not do is give every window a growable boundary: all same-orientation splits still share the one per-space ratio (#56’s settled trade — per-node ratios need a container tree, which the flat-array model forbids). Concretely: the inner window of a pair nested inside the second region has width r·(1−r)·W, which is maximized at the default ratio — no resize direction can widen it, and the visible effect of a grow press is its outer neighbor widening instead. The same is true when dragging that window’s edge with the mouse; keyboard and mouse stay in lockstep, warts included. This is an accepted limitation, not a bug to fix within BSP: a smarter sign (derivative-based) was considered and rejected — it cannot help the pinned case and would split the just-unified mouse/keyboard rule. The real answer is the track layout (#128, shipped), where every window sits in exactly one track and every resize has one true target. A floating focused window is exempt from all of this: it resizes itself directly, in every mode (width for x, height for y, floored at min_window_size) — “floating” being the effective float since #1184, whose entry below is the ruling’s home. (#122, #124, #129)

Resizing clamps at a window’s effective minimum, and a truncated attempt is cued, never silent (#933). A window’s resize floor is the configured min_window_size, raised where its app enforces a larger physical minimum of its own — learned from the engine’s refused asks (SizeBoundLearner, #677), since AX exposes no minimum-size attribute. Keyboard and mouse resizes share one set of clamped writers, so the two paths cannot answer the same gesture differently. A shrink the clamp truncates gets the tactile rubber-band bounce on the focus ring (DeadEndBump #436) and a frosted pill naming the reason ("Minimum window size reached"). Pairing the two vocabularies here is deliberate, not a breach of the “two distinct vocabularies — never merged” ruling (#435/#436, below): a minimum is at once a true edge — the bounce’s “nothing further” holds, there genuinely is no further — and a refusal with a reason worth a word, so the two cues agree, unlike the swap-onto-a-traveler case that ruling keeps pill-only.

Four rulings sharpen that:

  • The cue fires on the first truncated attempt. A shrink that lands ON the floor already refused part of the request; waiting for a second press once at the floor read as “nothing happened” the first time (the original #933 defect).
  • The two directions read different windows’ minimums. A shrink clamps at the resized window’s own floor; a grow caps where a NEIGHBOR would drop below its floor — per-window minimums (StackLayout.weightStep(minSizes:), the two-sided SplitDomain), never one blanket value. When a grow (or a shrink whose group floor is carried by a group-mate) is refused, BOTH ends pill, each with the copy that fits its anchor: the resized window explains why nothing moved ("Neighboring window at its minimum size"), while the blocking window marks itself ("Minimum window size reached"). One pill on the blocker alone read absurd there — from its own perspective IT reached the minimum, not a neighbor — and one on the trier alone leaves which window blocks unnamed; the #435 rule’s core survives (the window that cannot move is marked). The bounce stays on the resized window, whose gesture hit the wall.
  • A weight clamp divides the layout’s exact span. The ratio caps deliberately use the raw region span (a superset can never block reaching the visible bound; the render clamp is the net), but for the track/stack weight paths crossing the floor means an OverlapStack cascade, so the clamp subtracts the outer gaps and inner gaps exactly as the layout does — plus a small margin (StackLayout.minSizeMargin owns the number), because the clamp’s fixed point sits at exact equality with the cascade check and float noise alone could tip a clamped-at-minimum write into the pile. That equality gap is how #925’s clamp still collapsed a track space at the minimum.
  • A mouse gesture is measured from the pre-event frame. AX throttles move/resize notifications, so a fast drag’s first event already sits mid-flight and its last can lag the drop; the drop end re-reads the live frame (#245) and the START now anchors on the frame state held before the gesture’s first event — measuring first-event → last-event resized only part of the way.

The maximum direction clamps and cues too, where a learned ceiling can bind (#1055). An app-enforced maximum is learned the same way the minimum is (EffectiveSizeBound models both directions; maxWidth/maxHeight require the same two-distinct-asks corroboration as the floor, because a grid-snapping app answers a few points under an ask exactly as it answers a few points over one). The scrolling slot is where it acts: the one resize store holding an absolute length, and one slot serves the whole row, so growing it past what the focused window’s app will perform only slides the neighbors aside for a span the app snaps back from. Three choices sharpen it. The ceiling never reduces the shared slot — at the learned maximum a grow refuses rather than trims (since #1057 measured against the window’s drawn span rather than the store; the #1057 entry owns that rule), because trimming a row-wide value to one window’s limit would visibly shrink every neighbor on a grow press. The refusal pills ONE end, unlike the neighbor-minimum pair: the limit is the resized window’s own app, so there is no second window to mark; which sentence it draws is the #1261 entry’s, below. And running out of viewport stays wordless — that limit protects no window and names none, so the press is a silent stop.

A bound may refuse a press only if it was learned from a read that could tell a refusal from latency (#1083). [Principle] The clamp above rests entirely on the bound being true. It was not: the learner was confirming bounds from redraw latency, and the pill was then asserting limits that did not exist.

Measured on the owner’s Mac at load average 9.7 (2026-08-28, macOS 26.6.2): sixteen bound confirmations in eight minutes of ordinary use, at least fourteen false. Each sat at the window’s own pre-press width, one resize step apart (984, 954, 924, 894), with heights all equal to the slot’s — the layout’s own geometry recorded as the app’s limit. Two different windows confirmed an identical bound 44 ms apart. Resizing stopped, the pill named a limit the window was nowhere near, and dragging the edge by hand worked, which is what proved the app imposed nothing.

A refusal DRAWS; the sound is an addition to the drawing, and cannot fire without one (#1255). [Principle] Two refusals cued by sound alone — a resize press in a layout with no resizing (monocle, grid, and the floating layout until #1184 gave its members a resize of their own), and one on a zone axis that does not exist. Both were invisible with the toggle off, and invisible to anyone who does not hear it; the first is the most reachable refusal in the feature, not an edge, since any resize press in a layout that has none arrives there. Meanwhile the size-limit and sticky families drew pills and said nothing. One idea, four shapes.

So: every refusal draws, and refusal.sound adds the system alert to the drawing. The sound is gated on what the drawing REPORTED, not on the drawing having been asked for, and that is the invariant rather than a detail — a sound that cannot fire without a pill can never re-create the defect this removed. Asking is not appearing: both primitives decline silently, the size pill without the private runtime and the sticky mark without an overlay, so each returns whether it drew and one gate turns that verdict into sound. It is load-bearing for the sticky family, whose pill is gated on sticky.mark: with the mark off those refusals draw nothing, so they must say nothing, where a sound placed one level up — on the refusal funnel, or beside the drawing call — would have made them audible-but-invisible.

The setting is OFF by default, and the DECODER is what delivers that — the retired resize.feedback is no longer declared, so a stored true is an unknown key and every config lands on the new default whether or not the migration has run. The migration is hygiene: it ends the file in the new shape, because a dead entry left in a saved config reads as a choice somebody made. Nobody did — the old default was true and the encoder wrote the key unconditionally, so an explicit value records what a save did rather than what anyone chose.

Nor is the stored value worth carrying. The old cue was audible in two situations, one reachable only by height-resizing the master of a stack, and the owner could not trigger it in three attempts while looking for it. Widening that to every refusal while keeping the stored true would have made every existing install noisier at limits it currently hits silently. So the crossing drops the retired key rather than carrying it.

An arrow means a resize stopped; a non-arrow means there is no resize here (#1260). [Principle] The pill carries two kinds of message, and they ask for different things of the reader: structural — the parameter does not exist and never will, so stop trying on this layout — and contingent, a bound reached that something could get past. The distinction rides the glyph because that channel is already drawn on every pill, so encoding it costs no width and taxes the common case not at all; and because the glyph is the part that survives truncation, which is exactly the narrow band where the sentence has stopped being readable.

The symbol is read off the ResizeRefusal case in one exhaustive switch, never off the text — #96’s rule, and the compiler is then the forget-proofing, so no scan is owed. .neighborMinimum keeps the SHRINK arrow deliberately: a shrink whose group floor is carried by a mate routes through the neighbour cue, so a direction-derived glyph would draw a grow arrow on a shrink gesture. And every name must predate the deployment target — a symbol added later resolves on a modern dev host and renders nil on the target, leaving an empty gutter and no error anywhere.

Colour was refused, and in principle rather than in practice. ColorVision.separation measures a pair against a KNOWN ground, and this pill is a .hudWindow blur over whatever third-party window sits behind it — there is no pair to measure, which is the same reason the marks default to Automatic rather than a brand hue. A warning triangle was refused for its semantics: a layout with no resize parameter is a fact about the layout the user chose, not a fault, and the triangle would fire on the most reachable refusal in the feature — a warning on the commonest path becomes chrome, and spends an alarm vocabulary reserved for a real conflict.

A refusal names the app where the app is the limit (#1261). [Principle] A pill tells the user a limit was reached; what they do next depends on whose. KiwiDesk’s own floor — min_window_size, a neighbour’s share — is something the user can change: lower the setting, move the neighbour, pick another layout. An app’s own enforced minimum or maximum is not: nothing in KiwiDesk makes a window go where its app refuses to draw it, and the only honest advice is to stop pushing. One sentence for both sent users hunting a setting that was never the constraint. So the sentence carries the remedy — "This app won't go smaller", "Neighboring app won't go smaller", "This app won't go bigger" — while the glyph keeps carrying the state (the #1260 entry above): the remedy is the one distinction a glyph cannot hold, since an app limit is contingent by state and structural by actionability, and a third glyph in a slot that holds two would say neither.

The verdict rides the ResizeRefusal case (appBound) and is derived ONCE, from the same resolution every clamp measured against — a window’s effective minimum is max(min_window_size, learned app floor), so “which term won” is the same question at every path, and the refusal builders answer it rather than each call site by hand — a clamp that adds a floor of its own (the scrolling slot’s 100 pt) hands that floor in, never the verdict, since a learned floor under it is not what bound. The maximum needs no verdict at all: no configured maximum exists, so a learned ceiling is only ever the app’s. The cost, stated: naming the app is a stronger claim than naming a limit, and it rests entirely on the learned bound being true — the #1083 bet the maximum clamp was already making silently. And every sentence is written to hold with no press behind it, because the neighbour pair is also drawn by a retile (#934).

It lives in Behaviour rather than General, and that is a STORAGE decision wearing a placement question: every row in General is a UserDefaults preference, a live service toggle or an action, so a draft-and-Save row there would be the only one that does not do what it was just told. Keeping it in the draft config is what preserves the Lua verb and lets it travel in profiles and backups — the GUI curates, Lua is open — and Behaviour is where app-wide draft behaviour already lives. The cost, stated: Behaviour is Power-User-only, so a Simple user gets the pill and not the switch.

The cause is that an echo reporting the pre-ask frame is the same bytes whether the app refused or has merely not redrawn yet, and under load the second is ordinary for ANY app — this reproduced on Ghostty, the fast one. So the ladder’s two votes — seeding a candidate and confirming it — are only meaningful from a read that waited out the app’s chance to answer. Only the settle probe does. Raw echoes still seed, refresh and clear; they no longer promote. A genuine limit is learned one probe grace (~0.6 s) after its animation settles rather than at echo time, which is the whole cost.

The permissive alternative was ruled on and rejected, and the reasoning is worth keeping. The obvious durable fix is to stop a learned bound refusing a press at all — three separate paths can mistake latency for a refusal, each guarded by its own heuristic about whether the app has answered, and they degrade together under load. Being wrong permissively costs a window that does not fill its region (the accepted split-layout residue, self-correcting on the next retile); being wrong restrictively costs the user the feature and states a falsehood. On frequency alone that argues for permissive.

It was implemented, measured, and then reverted on the owner’s ruling (2026-08-28): with the learner fixed, the bounds it now produces are real — device capture showed the same eight minutes of use going from sixteen false confirmations to zero, with subsequent confirmations landing on plausible app minimums (500, 400, 825) — and a window resizing past what its app will follow, leaving a neighbour overlapped, is worse than a stop that is almost always correct. The permissive rule is the right answer when bounds are guesses; it is the wrong trade once they are facts. Should a fourth latency path ever be found, this entry is the argument for reaching for it again.

A press writes forward, never across the store (#1083). The layout draws a bound-pinned window at its learned limit, and a press measures from that DRAWN span (#1057). Where the drawn span sits on the far side of the store, that base made the press write across it: a grow from a pinned 715pt window inside a 1160pt auto slot wrote 765 and trimmed the row for every neighbour, and the shrink mirror raised a 300pt store to 775. The base is therefore whichever of the drawn span and the store lies FORWARD of the press — max for a grow, min for a shrink — which keeps both of #1057’s cases and makes crossing the store impossible by construction rather than by a guard. A guard was tried first and was worse: it swallowed the press with no write AND no cue, which is a refusal that cannot explain itself. A press that does nothing always says why.

A resize press is measured against what the focused window DRAWS, and refuses in place where its bound blocks it (#1057). [Principle] The scrolling slot is a shared store, and two symptoms came from resizing it by the stored number: an oversize configured slot (set at the desk, applied on the laptop) made shrink presses move an invisible number for several clicks before anything responded, and a window pinned by its learned bound let presses silently resize every NEIGHBOR — grow walked the store up through the row until it caught the pinned span and only then said “maximum reached” (owner device QA, 2026-08-28). The rule that fixes both: the press acts on the focused window, so it is measured from the span that window actually renders. Where its bound blocks the direction outright — grow at its maximum, shrink at its minimum — the press refuses in place: the pill on the first press, nothing written, no neighbor moved; resizing the row from a window that cannot follow is done by focusing a window that can. Where the window CAN move, the press acts from its drawn span — an oversize store shrinks visibly on the first press and is rewritten only by that deliberate act (a grow still refuses, per the #966 config protection), and a window pinned above the store grows in one press instead of walking the store up to it. The whole decision lives in one pure type, ScrollSlotDomain, so every cap arm is a unit-tested case rather than arithmetic in a command file — the same shape the ratio clamps take in SplitDomain. (ScrollSlotDomainTests, ScrollingFixedSpanCueTests)

A held resize chord glides — and only resize (#1056/#1082). [Principle] Every other keyboard adjustment on a Mac repeats while held; resize was one press per step by construction — a Carbon hot key delivers exactly one press and one release per physical hold — so KiwiDesk synthesizes the hold itself. What holds is decided by what the press DID, not by what the binding says: a binding’s body is opaque Lua, so KiwiCore.execute tallies every command run inside a hotkey fire, and a hold arms only when the press-fire executed exactly one command, it was resize, and it succeeded. focus and swap are deliberately out — overshooting focus is worse than pressing again — and widening the set (HoldGlide.glidableCommands) is a per-verb ruling, never an inference.

A hold GLIDES rather than repeating (owner ruling, 2026-08-29, replacing #1056’s interval acceleration). #1056 re-fired the binding on a shrinking timer, which felt chunky on device for a reason no constant could fix: the repeat engine decided only when to fire, never how much, because the amount lives inside opaque Lua — so acceleration shortened the gaps and left the jumps identical. And speed and smoothness are ONE dial, not two: what the eye judges is displacement per rendered frame, and the display draws when it draws, so ticking faster than the refresh produces no extra frames, only more accumulated movement in each. So the hold now runs as a continuous session on the monitor’s own DisplayLink, moving velocity × dt per frame. Riding dt rather than a fixed per-frame delta is what makes it refresh-rate independent — 60 Hz, 120 Hz and a ProMotion panel changing rate mid-hold all travel at the same visual speed, with a faster panel buying finer motion rather than more speed. The press keeps its full configured step, so a tap still moves a predictable amount, and what separates a tap from a hold is the system’s OWN key-repeat delay (NSEvent.keyRepeatDelay, read per run at the arming press) rather than a threshold KiwiDesk invents: the user already tuned that number for every other key on the machine. That is the surviving half of #1056’s “timing is the user’s” ruling — the repeat INTERVAL is gone, since the glide has no interval.

Velocity is counted in steps per second, not points per second. The issue proposed absolute points; the ruling went the other way, because resize’s delta is in points at every call site and resize.step spans four decades (the decoder clamps it to 1…10000), so one absolute speed is discontinuous with the tap at both ends: a 10 pt precision step would be overridden by an eighteen-of-their-steps-per-second glide the moment the user held, and a 200 pt step would make holding slower than tapping. Scaling the press’s own delta keeps the glide continuous with the tap at every setting. The feel constants live beside HoldGlide.glideSteps and are the owner’s to retune.

The glide re-issues the COMMAND, never the binding. The press’s resize arguments are captured from the tally and re-issued through execute with a scaled delta, so the Lua body runs once, on the press. This is a deliberate semantic change from #1056, where a tick re-ran the whole body: at frame rate that would repeat whatever else the body does, and the single-command tally already refuses to arm on such a body — so re-issuing the command is what makes the arming rule and the run agree. It follows that a body which rebinds mid-fire arms nothing: a rebind mints fresh ids, so no release for the pressed id could ever arrive to stop the hold. Where that question is asked, and why the glide is the layer that has to ask it, belongs to .claude/rules/input-and-animation.md.

A glide’s writes are instant, on every resize path. The glide already is the motion, so springing each frame would smooth an already-smooth signal, add ~100–200 ms of trailing behind the key, and generate the #611 retarget storm deliberately — a changed target every frame is exactly what the settle watchdog cannot tell from a long drag. Writing instantly creates no animation, so there is nothing to defer.

That was true of the tiled paths from the first build, and of the floating one only after #1090, because of what each measures from. A tiled path writes a stored ratio, weight or length and re-derives geometry from it, so an instant write leaves the next frame’s base exact. resizeFloating measures from a frame, and the only commanded base it trusted was the in-flight animation’s target (#129/#1056) — which an instant write does not create, and which AnimationEngine.animate never creates at all under Reduce Motion, with animations off, or with the engine disabled: it opens guard isEnabled, !reduceMotion(). So that path fell back to the echo-fed frame, and at glide rate most frames re-based on the same stale echo. Measured on device, 100 asks at ~102 Hz travelled 29% of what they asked for: the window crawled while the key was held, and Reduce Motion was the configuration that got it.

So the floating path was given a commanded base of its own, bounded by the hold. It records what each write commanded, in GlideCommandedBase on the animation engine — deliberately beside the animation target it stands in for, so a caller asks one accessor rather than branching on which store happens to hold the answer. The hard part was never the record; it was the bound. #1056 had already tried the #881 instant stamp here and rejected it, because a commanded record every press can read is re-armed by every press, so an app that silently refuses every ask banks growth with no ceiling (the #1057 class) — and at glide rate a 30 s hold at the ramp’s top speed is many screens of banked travel, not one press’s worth. This record is bounded at both ends of its life instead. Only a glide step may read it, so no press can ever measure from another press’s record — that is the bound the #1057 objection asked for. And it is retired at the start of every physical press, which is a different job: it stops a record left by an unrelated earlier press being read by a later hold that reaches the same window. The second bound has to hang off the PRESS rather than off the glide’s end, and both review lanes caught that independently — the end-of-run seam fires only for a run that actually glided, so a tap’s record would stand forever, and on the refusal path it fires from inside the very command that then records. A refusing app therefore moves nothing, banks nothing past the release, and the next press measures from reality. What stays accepted is the per-press residue — a press with no animation in flight still re-bases on the echo — which is what that read gate is protecting, and is recorded in accepted-limitations.md.

Reduce Motion gets no branch of its own, and that is the point of doing it this way. Because a glide frame writes instantly for everyone, no animation exists during a glide in any configuration, so the record is the single base on all of them — there is nothing to keep in sync. A held chord therefore glides under Reduce Motion rather than being suppressed: a held-key resize is the keyboard’s direct manipulation, which Reduce Motion does not suppress for the mouse either. An earlier version of this entry claimed the instant tiled writes were already the whole of that answer. They were the tiled half only, and the floating half was where Reduce Motion did the damage — an accessibility setting quietly degrading a headline behaviour, which is what moved #1090 from deferred polish to release work.

A refusal ends the run: the #933/#1055 size-limit cues stop the glide, so a held shrink parked on a floor pills once per hold rather than per frame, while scrolling’s wordless out-of-screen stop keeps gliding harmlessly — matching that silence’s own ruling rather than inventing a signal for it. The cue is heard during the glide as well as the press fire, since the glide runs outside any binding fire. Structurally, the engine arms only when its registrar can report releases (HotkeyReleaseReporting — a hold with no stop channel must never start); any registration teardown (layer switch, recorder suspend) ends the run, because an unregistered hot key delivers no release to stop on; and a run is bounded by HoldGlide.maxRunSeconds, the #611 force-settle shape — the stop signal is one Carbon event, and a lost one must cost a bounded hold, never the session. That bound is spent in simulated frame time, accumulated from the frames actually delivered, so a starved main queue cannot age a hold it never ticked — with a wall-clock backstop of the same length beneath it, because the frame clock is bound to one screen and display sleep or a disconnect mid-hold stops it, and a net must not depend on the thing that died. A floating resize also stopped under-accumulating (#129/#1090): a write accumulates against what was last commanded rather than against the lagging AX echo — the in-flight animation’s target where one exists, and GlideCommandedBase where none can. What a commanded record stored here has to have is a BOUND, and each of the two has its own: the animation target dies at settle, and the glide record is readable only by a glide step and retired at the start of the next press. Neither can be re-armed by an ordinary press, which is what a stored commanded frame does otherwise — banking growth without ceiling on an app that silently refuses every ask (the #1057 class). The per-press paths that still re-read the echo are recorded in accepted-limitations.md. (HoldGlideTests, HoldGlideRunTests, HoldGlideRampTests, HoldGlideWiringTests, HoldGlideRefusalWiringTests, HoldGlideSeamTests, HoldGlideEligibilitySeamTests, FloatResizeAccumulationTests, FloatGlideAccumulationTests)

A corroborated bound generalizes at the consume site, revocably; entries never do (#1055). [Principle] The per-ask ledger exists because a single refusal is grid noise as often as a bound — a terminal answers each ask a few points off — but corroboration changes what the evidence supports: two asks a real step apart agreeing on one answer is a signature a nearest-cell snap cannot produce below the quantum the distinctness bar protects (corroborationDistinctness derives the arithmetic; the measurements are on the issue), while a true fixed bound answers every ask past it with that one span. So consumedWidth/Height and explains answer an ask beyond a corroborated bound with that bound, which is what stops a scrolling row — one slot size serving every window — from re-running the whole learn dance per resize press. Generalizing at the CONSUME site rather than in the ledger is what keeps it revocable, and the revocation has three working parts, each ruled deliberately. A per-ask entry outranks the generalization — and because the consume rewrites the ask the ladder sees, the generalized answer re-resolves once through the entry at the bound’s own span, so an app that contradicts the bound (an aspect-coupled emulator after an other-axis change) revises every generalized answer through the ordinary ladder. An explicit apply PROBES past corroborated bounds — a forced pass genuinely re-asks the app once, then the refusal it observes mints the exact entry later passes consume — so the user’s own re-apply remains a reliable clear for a stale bound. And the genuine-resize forget and compliance sweep clear stale entries as they always did. One extension rides the same evidence class: a corroborated ceiling corroborates the single floor entry at the same span (and mirrored) — an app answering one span from both directions is the fixed-width signature — which is what arms the shrink refusal cue on the first press below a fixed-width app’s span instead of after a long silent walk. The lend consults only the paired value of the other direction, never a lent one, so two single entries cannot bootstrap each other. (SizeBoundGeneralizationTests, ScrollingFixedSpanCueTests)

[Trade-off]

The corroborating ask is sent, never awaited (#1439). An entry confirms about a second after a window arrives, but everything that needs a CORROBORATED bound — the size-limit pill, Scrolling’s re-pack past the entry’s own ask, Track’s count and floor re-share — waited for the layout to happen to ask a second size a step away, which on a quiet screen it never does (the owner measured 8–18 s before and under a second after; both tables are on the issue). So the moment an entry confirms on an axis nothing corroborates yet, the learner arms one probe a step past the refused ask in the refusing direction, and the retile loop sends it in place of the ask the entry already answers — never a new one, and never on a forced pass, whose contract is the layout’s own ask. Its first observation may trust the pre-ask frame, which #1083 otherwise forbids inside the echo grace, on four terms: the anchor’s confirming read was settled and so outranks the set the grace protects, the trust is consumed by one issue, killed by any ordinary ask, and checked against the anchor’s answer at issue. What this trades: a grid-snapping app pays one extra ask per anchor, answered a few points off and corroborating nothing, exactly as the #1055 bar intends — and where that answer lands inside the match tolerance, or the app simply performs the probe, the window holds a size no layout drew, so a performed probe — decided by the settled read alone, since a raw compliance can be the transient half of a snap-back — is answered with the retile the compliance sweep would not send, the sweep re-learns the entry once, and every ask that has had its probe stays probed for the ledger’s lifetime rather than re-arming on that re-confirmation. A second anchor confirming while a probe is pending waits for the layout, as before, and an entry already sitting within the match tolerance of the probe’s span absorbs its confirmation, so that pair stays a hair inside the bar until the probe runs out of issues — a hand-drifted ask one step off the layout’s, accepted as rare. It is not the timer re-probe the limitations table rules out: one probe per anchor, re-issued once and only once answered, never chained off its own confirmation, and ring-invisible, since the overlay pins it at the anchor’s answer as it pins the second probe at the candidate’s. (SizeBoundCorroborationProbeTests, SizeBoundCorroborationProbeLifecycleTests, SizeBoundCorroborationProbeEngineTests)

[Principle]

Track’s automatic count reads learned minimums; a fixed limit does not. With auto_tracks on there is no stored count: the number of tracks is derived on every retile as the geometric fit, and until #1355 that fit divided the span by the one global min_window_size, so a row of Xcode, System Settings and an Electron app with a minWidth opened as many tracks as 300 pt windows would and overlapped. The derivation now takes the largest prefix whose tracks — each at the larger of the global minimum and its members’ corroborated floor on the cross axis (#677, the same ledger Scrolling and Monocle consume, read as the raw corroborated floor the #933 clamp reads rather than the chained span the consume emits) — fit the span with their gaps, in the one TrackLayout.geometricCap, which the render, the swap guard and the weight heal all read. Two consequences were designed for rather than discovered: the learned minimum arrives lazily, so a fresh space may settle one retile later than before, the latency Scrolling already accepts; and a window with no learned bound still counts at min_window_size, so the cap can never be lower than the old answer before learning and only tightens as bounds arrive. A hand-set limit is deliberately untouched: it is the user’s number, and a limit that quietly shrank under a learned floor would contradict the setting the user can see. The count is only the feasibility condition, though: equal weights split a 980 pt span 490/490, and a 600 pt floor overlaps its neighbour with the count already right. So the retile-time heal (below) gained a second pass that re-shares the track weights until every track draws at least its own floor — pinning a sinking track there and handing the rest to the others by weight — rather than the count being tightened to whatever equal shares could hold, which would have piled a wide app’s neighbours into one track for want of a share the span could afford. A forced apply probes past the learned floors like every corroborated-bound consumer (#1055), and its heal folds on that same plain cap, so a floor that lifted is re-asked once and never pins a count it no longer earns; the residue pass a mid-pass confirmation triggers takes no heal, inside the one-retile latency above. At the boundary the render’s exact cascade check wins: a re-share never leaves a track under min_window_size, tolerates only the bound quantum under a LEARNED floor, and never writes the exact fit when the shave’s margin does not fit beside it. And the heal gauges the count over the local members while the render folds the effective list, so a home sticky rendering elsewhere can carry its floor into the heal’s count and not the render’s — one track fewer in the heal than on screen, the away twin of the visitor horn below, transient in the same way.

[Principle]

A split store heals to a learned floor at retile, and the shared ratio may move for it (#934/#1430). The split-layout row of the accepted limitations refused this for a long time on one argument: bsp’s ratios are two per-Space scalars, so moving one for a stubborn window moves every same-orientation split. The owner’s ruling (2026-09-14) accepts that distortion, because a resize press by hand moves the same number the same way — the #933 capped writers already clamp a press at a learned floor and the user sees the shared split move; a heal moving it to the same place is not a new kind of change, only an earlier one. Two sightings drove it: a window ARRIVING into a slot narrower than its app’s minimum overhangs its neighbour until someone resizes by hand (#934), and a resize that walked the split past the floor in the presses before corroboration stays past it — the pill says minimum size reached and nothing snaps back (#1430). One mechanism answers both, and the choice of that mechanism is the second ruling: a retile-time heal of the split stores at the KiwiCore.retile choke point, the healTrackFloors shape one store over — idempotent, reading corroborated floors only, over the LOCAL members, standing down on a forced pass like every corroborated-bound consumer. The one-shot write at the confirmation edge the issue first proposed was rejected as a second mechanism on one store: the heal already runs on the retile that edge triggers, and an edge write and a heal cannot disagree about where the store should sit if only one of them exists. The write goes through the capped writers with no focus, so a neighbour yields only down to its own floor and nothing cues — a stored-parameter change, visible, undoable by a press, travelling with the Space like any interactive resize, which is also what keeps the bsp and stack algorithms bound-blind: the post-pass reads the bounds from the context, as Scrolling and Monocle do, and the algorithms read none. The stack zone’s per-window shares stay out, per the #944 ruling; Track needs nothing, since #1355 re-shares its weights at retile already. Where the yield cannot fit — a same-axis neighbour already at its own floor — the engine says so ONCE per episode, at the first retile that finds it and shows the space, with the neighbour-minimum pill on the window that overhangs and the neighbour marked, drawn by the retile without a press’s glide note or bump — and without its sound unless a press is in flight, the speaker’s own gate — re-armed when the window fits again or its bound is forgotten; and the overhang lands INWARD (owner ruling 2026-08-31): the frames the retile ISSUES take one post-pass that emits a floor-bound slot at the floor and pulls it back inside the layout region, so a window that cannot fit overflows toward the screen’s centre rather than past its edge, where part of it was unreachable — a floor wider than the region itself keeps its leading edge, the one carrying the window’s controls — while the slots every reader classifies against stay the layout’s own regions, because an inward frame overlaps its neighbour and a slot reader would call that a pile. Which of the two overlapping windows is on top is the focus order, as for any overlap; no z-order restore is armed for a residue the layout constructs on purpose. The write lands where a press lands — in a declared per-Space ratio where one exists — so an arrival can mark the profile dirty and a Keep persists the healed number; accepted with the ruling, since the number is the one a press would have written. What the heal reaches is what a resize reaches: the first split of each orientation in bsp, the master/stack split in stack. A floor inside a deeper bsp split is the flat-array trade’s residue and stays in the limitations row. (SplitFloorHealTests, SplitOverflowTests, SplitFloorHealWiringTests, SplitFloorCueTests, SplitFloorHealNeedleTests)

Session weights are healed at retile, not validated forever at write time (#944). [Principle] The write-time clamps above validate a weight against the membership at PRESS time, and that is the only moment they can see: a track opening later, a member joining a track, or the span shrinking (a display change, waking to a smaller screen) can leave a legally-written weight squeezing the smallest share below min_window_size — and the layouts answer infeasible weights by collapsing the whole group into an overlap pile, which live QA read as “resize is broken”, not as physics. So every layout pass re-checks the track session stores against the CURRENT membership and span and shaves the extremes: a waterline cap derived from the same maxColumnTotal/weightedSpan authorities the clamps and the cascade check share, landing the smallest share exactly at the margined minimum, touching nothing below the cap, and logging itself (a silent heal removes the symptom that makes a defect findable). Healing at the retile choke point rather than at each membership-change site means no site can forget to arm it and no latch can go stale; healing rather than piling because the pile destroys the whole arrangement to preserve a number the user has no way to see. Two derivation rulings sharpen where the heal reads its inputs. It reasons over the RENDER’s folded partition (overflowCap), not the clamps’ per-marker one: the heal’s target is the render’s own cascade check, and under an active overflow fold the per-marker reading both declines to heal an arrangement the folded render still piles on, and over-shaves weights whose folded render tiles fine — “tighter in the safe direction” is the CLAMP’s argument, where tighter costs an early refusal cue, and it inverts for a rewrite of stored state. And it reads the LOCAL membership, never the traveler-injected list: a visiting tiled-sticky window is transient, and healing against it would permanently rewrite stored weights for an arrangement that departs with the traveler — the data loss the traveler rows in the accepted limitations promise never happens. The cost is a possible transient pile while a visitor tips the check, the same accepted class as the traveler weight wobble; the heal targets the steady state that remains. The same ruling read the other way — the DEPARTURE direction — is accepted too: while a space’s own sticky renders elsewhere, the heal still counts it (it is a local member), one more than that moment’s render, so a limit-grazing weight written during the absence can be shaved at a later retile. That write-then-shave churn is the steady-state ruling’s cost, not a defect to fix by loosening the heal: when the sticky returns, the shaved weights are exactly the feasible ones. Count-driven overflow is untouched: when the span cannot hold the members at ANY weights, the overflow folds stay the honest answer. Deliberate residue: the stack layout’s zone shares keep write-time clamps only — a zone’s membership shifts with master_count and spawns too, but its overflow degrades to a cascade inside the zone, not a whole-space collapse, and the heal joins it only if live QA ever measures that class. An explicit balance verb stays a possible future escape hatch, not shipped — the heal removes the defect, and 1.0 adds no new configuration axes (#663).

The focused ring stands down while an own key window that is not the focus anchor is active (#933). Sparkle’s update alert is an own titled dialog — tracked and force-floated, per OwnWindowTiling’s census — but when Sparkle’s progress window closes to yield to it, the destroy fold re-points state focus at the background survivor (#929’s flow) and no focus event re-points it at the alert, so the ring kept drawing around the stale anchor behind the alert. While the process holds a key or modal window that is NOT the focus anchor, the focused ring is suppressed (EventLoop.ownKeyWindow — the one seam the #929 close-return raise stand-down also reads, through a narrower facet: ANY own key window makes the anchor stale, so the ring reads the broad number, while only the #935 dialog class may bury a close’s successor, so the raise reads isDialog), the same answer a focused launcher gets (#300); an own key window that IS the anchor — the Settings window — keeps its ring.

A floating keyboard resize is symmetric, with pinned edges (#1091). [Principle] FloatResize anchored at the origin, so a resize moved the right/bottom edge only — the mouse-drag-the- corner idiom, where the grabbed edge is the anchor. That is right for a drag and wrong for a chord: a keyboard resize has no grabbed edge, so privileging one is arbitrary, and against a screen edge it stopped the resize dead. Measured on device: a float parked with its right edge on the screen edge took 10 further grow asks and moved 0 pt, silently, with 892 pt of free space sitting to its left.

So the delta now splits between both edges. An edge against the boundary is pinned and the whole delta goes to the other side; with both pinned a grow refuses and cues, while a shrink contracts symmetrically as normal — refusing a shrink would strand a wall-to-wall window at a size it could never leave. The pinning applies to shrink as well as grow, and that is the load-bearing half rather than a symmetry for its own sake: pin only on grow and grow/shrink stops being reversible at exactly the edge people park windows against. Every steady state round-trips.

One residue is accepted rather than overlooked: reversibility does not hold across the step that first brings a window into contact with a boundary. A window with 28 pt of room on the right grown by 100 spills the blocked 22 pt leftward and pins its right edge; the following shrink then comes entirely off the left and lands half a step right of where it started. It is bounded by half a step and fires only on that transition, and it is strictly better than the silent no-op it replaces. Do not answer it by remembering which way the last grow went — a stored direction needs invalidating on every move, mode change and display change, and buys back less than it costs.

The boundary is the screen edges and the bars together, derived once. KiwiCore.floatBounds is the one answer to “where may a float sit”: the display’s visible bounds with every painted strip carved off its own edge. It carves the strips the bar managers actually painted rather than routing through layoutBounds, for the reason the float nudge already does — an empty bar is suppressed while layoutBounds still reserves its strip, so routing would bound a float out of a region no bar occupies. Bars vary per space (one or two, on any edge), so it folds both strip lists; two strips on one edge leave the deeper carve standing, which is what makes the fold need no ordering rule.

Size is bounded there; position stays the user’s. The retile-time net fits an oversized float back inside the region — the clamp beside it only ever moved a window, so one larger than the space between two bars was pushed to one side and still overflowed under the other. It deliberately does not enforce the screen edge, because that net runs for every float on every retile and would drag back a window parked half off-screen by hand, which macOS allows and this change never asked for.

And the ring is kept clear, not just the window. A float is held the ring’s own outward reach off every edge of that region — bars and screen edges alike. The ring is the window frame outset by that reach and paints at .normal while bars paint at BarPanel.level, so a window flush against a strip had its outer sliver hidden; flush against a screen edge it was clipped instead. Device QA caught the first version insetting at bars only, which is two rules where the principle gives one: float geometry follows PAINTED chrome, and a ring is painted wherever it is drawn. The number is not invented for this — it is BorderGeometry.outwardReach, the renderer’s own function, and BorderStyle.fittingGaps already answers the same question for the layout with the same value on all four edges.

A tempting alternative was rejected on that same principle: to follow gap.outer instead, so border.fit_gaps would cover floats. A gap is a layout reservation and nothing is painted there — the recorded reason floatBounds carves painted strips rather than routing through layoutBounds is exactly that an empty bar is suppressed while the reservation stands.

The inset applies whether or not the window is focused, which is the point rather than a simplification: one that tracked the ring’s actual presence would shift the float every time it gained or lost focus. And it goes to zero with borders off, so nothing is reserved for chrome that is not on screen.

A resize nobody asked for is corrected on its own event (#1358). [Principle] macOS’s title-bar double-click zoom, its double-click on a window edge or corner (expand to that screen edge), and an app re-sizing itself all reach the engine as a plain .windowResized — there is no zoom notification to listen for — and none is an ask of ours or a hand-drag. The edge double-click is the one that looks like a drag from inside: the press sat at the slot’s edge and was released under a second ago, which is exactly the trailing event of a fast hand-resize, so the press record carries AppKit’s click count and a double-click released there is read as the expand, not the drag — unless a drag on that window is already in flight, whose trailing events stay its own. For a TILED window the zoom is the layout’s to undo (owner ruling 2026-09-13): in a tiling layout a double-click on a title bar is a slip far more often than an intent, and a window left at the OS-chosen size sits over its neighbours until some unrelated event happens to retile — which reads as the tiler having given up. For a FLOAT the frame stays the user’s, as a hand-resize does, but the zoom fills the screen under a bar, and the bar clamp and the region fit are exactly the nets a dropped or resized float already gets. Both corrections are the ordinary retile’s own work, so the arm decides only WHETHER one is owed: a resize that is not our ask’s echo (that one is #677’s answer channel), not ledger-explained (a late echo), not a gesture (the drag pipeline’s), and that leaves the window off the frame a SHOWN space gives it — every display’s, since the layout places them all — read from the layout’s own frame set for a tiled window and from the bar sweep’s own verdict for a float, never a second copy of either. A resize inside the retile tolerance, a window on a Space nobody shows, a float under no bar, a native-fullscreen one — nothing; and nothing while a boot or wake burst defers event retiles (#672), whose one trailing pass corrects it anyway. The cost accepted: an app that animates its own resize triggers one retile at the first off-slot frame, after which its echoes fall inside our ask’s grace and are read as answers, which is the #677 dance an insisting app already pays. An app that takes the slot and reverts LATER than that grace would be corrected forever — each correction wipes the #677 ledger, so its twice-refused rule never accumulates — so a memo of the same shape ends it: two consecutive corrections and the window is left standing until it is seen on its frame again or the memo ages out (UnsolicitedResizeTests).

A corner is never a float’s original, and a stranded float is re-centred (#1352). [Principle] The stash restores a parked float from the capture taken at its first park, and that capture can be lost while the window still sits at the corner — a late park echo read as a user move, a Desktop switch sweeping the departed window’s entry, a relaunch replaying the parked snapshot, a profile switch turning a tiled space floating. Four paths, one end state: the next stash captured the corner as the place the window belongs, and every activation delivered it there. Four patches would each have closed one path and left the class open, so the rule sits at the value instead: stash refuses to capture a frame that looks parked, and a float on a shown space with no capture and a corner frame is seeded a centred one inside floatBounds for the restore pass to deliver (KiwiCore.recoverStrandedFloats). Centred rather than re-anchored, because there is nothing to re-anchor from — the original is exactly what was lost. And the session snapshot records the capture rather than the state frame, so a relaunch puts the window back where it was instead of in the middle.

The corner test tolerates what the OS does to a park. macOS lifts a parked window off the line it was asked for (the looksStashed docstring holds the device measurement), and a 2 pt tolerance read every lifted park as a user move, which is how the first of the four paths opened. The y match is loose by one visibilityFloor, derived rather than restated, since the floor is the most the OS moves a frame to keep it reachable; the x match stays exact, because a 1 pt peek flush with the screen edge is where nothing but the park puts a window.

A window that left with its Desktop keeps its memory. The Desktop sweep was a loss KiwiDesk caused itself: the restore pass swept the capture of a window that merely vanished with its Desktop while the away ledger (#1146) still knew it, and the recovery then centred what could have been restored. The sweep now spares every id the away ledger knows — the id is the same window when it returns — so the centring stays what it is meant to be, the net beneath a memory that is genuinely gone.

A space entering floating mode gathers what the previous layout left off-screen, scoped by visibility (#1177). [Principle] A floating layout assigns nothing, so a space switched to it keeps the frames its last layout drew — and scrolling’s scrolled-out columns and monocle’s parked pile are drawn out of reach on purpose. Switching to floating therefore lost windows behind the visible ones or off the screen, which the owner ruled crucial and frequent (2026-09-09). The rule is TRIGGERED by what is REACHABLE, never by which layout came before: a member partly or fully outside the space’s float region trips it, and so does a member whose frame sits entirely inside another member’s — a pile, one of the two behind the other whatever the z-order, which is what a monocle STACK is (owner ruling 2026-09-14, the second of the day: “stack should pile the same”). With every member inside and none piled, nothing moves. A plain tiled→floating switch then never trips it — tiles never contain each other — monocle and scrolling are covered without a mode matrix, and partly-outside counts (owner ruling 2026-08-31) because a sliver on screen is not a reachable window. The pile test is containment, not overlap: two columns that merely cross are both grabbable. Two cases the test reads as a pile are priced rather than exempted: a flag float parked inside a tile by hand, and a tile an app has overgrown onto a smaller neighbour (a #677 bound) — that neighbour IS unreachable, so the verdict stands. Once tripped, EVERY member takes the quit gather’s grid (#197) — the exit’s own function and depth, so a retune of the exit retunes this. The issue had the visible members staying put; the device showed why not (owner ruling 2026-09-14): two untouched columns beside a grid of the rest read as a mess, and the gathered windows laid exactly behind one another. The whole-space grid is what keeps a pile of columns findable rather than stacked at one edge, and it is laid inside the grow bound — the painted strips carved off and the focus ring’s reach reserved on every edge — so no gathered frame lands under a bar and the clamp has nothing left to push; the judgment itself takes the correctness bound, or a float flush with a bare screen edge would count as outside.

An entry is a change in what was DRAWN, not in what was written. The retile keeps the mode each space was last drawn in and gathers where the live mode is floating and the drawn one was not. That is the difference between a switch and a replay: a config reload resets every mode and re-declares it with no pass between, a session restore re-states a mode whose entry was gathered when it happened, and the boot’s first pass meets spaces no pass has drawn — in each the frames are the user’s, and a gather would have dragged in a float parked half-off by hand, which the retile-time fit already refuses to do. A RE-FILE is an entry without a mode change: a profile switch’s partitioning and a dropped Space’s forwarding (#1230) hand a floating space windows whose frames are the layout’s of the Space they came from, whatever the receiving space was drawn in — so each re-file records the window it moved and the next pass gathers the floating space that window sits in, never one it merely passed through; a float the user parked half-off by hand beside it comes in too, the priced trade. Delivery rides the stash seed, the #1352 door, for its reason: one path, delivered by the pass’s own restore on a shown space and kept by the park for the activation on an unshown one; and it is seeded ahead of the strand net so a monocle pile at the corner takes the grid, never a second centring. The bar clamp learned to judge a pending capture rather than the state frame it is leaving, since a fit of the stale frame landed after the delivery and undid it.

A restore pays a window it could not set at that window’s arrival (#1362). [Principle] The session restore replays frames onto tracked windows, and a slow app’s window is not tracked yet when it runs — so it was adopted later into its remembered Space carrying the frame the boot scan had tiled it at, on the main display, while its Space was a floating one on the other display, which assigns nothing. Two fixes were on the table: re-anchor the late adoption proportionally onto its Space’s display, or keep the snapshot record and pay it at the arrival the way a Desktop return pays its owed focus (#1207). The second is the rule: it restores the exact frame rather than a proportional one, it covers a float on the SAME display whose frame the scan changed, and it needs no display arithmetic. The frame rides beside the .restored Space memory and shares its lifetime, is consumed once at the first arrival, and is paid through the stash seed so the arrival retile delivers it where the Space is shown and the park keeps it where it is not.

The screen-home stand-down stays on the float flag. The #1286 sweep deferred one reader here: screenHome re-files a window returning on another display into that display’s shown Space, standing down for a flag float. The discriminator is what a re-file changes: the flag travels with the window and survives one, so a flag float keeps its home wherever it lands; floating-mode membership is the SPACE’s, and is exactly what the re-file rewrites. So a floating-mode member follows the screen by ruling — standing down would keep a home whose layout assigns no frame on a display the window is not on, which is this issue’s strand by another door.

The tiled→floating toggle nudges the window, and the nudge is a fixed magnitude, not proportional. A window keeps its exact frame the instant it turns floating, so make_floating / toggle_floating looked like they did nothing — no acknowledgement of the state change. The float direction now gives the window a small shove toward its screen’s visible-frame center (the tiled direction already animates a real move back into the layout, so it needs none). The magnitude is deliberately fixedmin(24 pt, distance to center) along the unit vector to the center — rather than proportional to the window size: a size-scaled nudge (longest-side × 0.2, say) teleports a maximized window clear across the screen while barely moving a small one. The fixed form self-tapers instead — a window already near the center has a short distance term and so moves less, reaching zero with no edge special-casing; a dead-centered window (direction undefined) shoves straight down. The target is clamped fully inside the visible frame, exactly like tiled placement, so it can never land under the menu bar / a reserved bar strip or partly off-screen, and it rides the existing relayout animation so the motion reads as a deliberate move, not a jump. Fires on the explicit float verbs only — make_floating and a toggle_floating that lands on floating — once per tiled→floating flip, never on an already-floating window. make_auto is deliberately excluded: its flip is detection-driven, not a deliberate user float, so it gets no acknowledging nudge. Fixed, not proportional, is the whole point — recorded here so it is not “optimized” back into a size-scaled form. A niche polish behavior, so the disable knob (set_float_nudge, default on) is Lua-only with no Settings toggle.

resize reads the effective float, so a floating-mode space resizes like a flag-float (#1184). [Principle] A window can be free-floating two ways: it carries the float flag, or it sits in a space set to the floating layout, which assigns no frames at all. The gate asked the flag alone, so the same window, in the same space, under the same shortcut, resized or beeped resize not supported in floating depending on a flag the user never had to set to get the behavior they were looking at. Nothing downstream of the gate distinguishes the two either — the region a float may grow into, the symmetric split and the pinned edges are all read off geometry, not off the flag — so refusing was a difference with no reason behind it.

It is ruled per verb rather than in one sweep, and that is the load-bearing half. #1178 introduced the effective-float predicate for nets — corrections that place a window nothing else will — and deliberately left every verb on the flag, because a verb is the user’s own ask and each one is a separate product question. Resize crosses because a floating-mode member has no layout answer to give and a frame of its own to change; whether the z-order raise, the Space Bar’s float badge and the focus ring should follow is a question about what each of those means, not a consequence of this one.

The ring and the float-tier raise follow; the badge stays on the flag (#1286). [Rationale] Of the flag’s readers, most are the flag’s own identity, a net already on the predicate, or the “is this a tiled member” question the predicate’s docstring rules is not its negation; the three the #1184 entry named are each answered by what they mean. The unfocused ring reaches every float, flag and floating-mode alike, because the exclusion it replaced had nothing behind it: #278 excluded flag-floats from the unfocused set without an argument, a floating-mode member rang anyway since the code asked the flag, and the two are the same thing to the user. Ruled in rather than out (owner, 2026-09-13): a ring sits behind its own window, so an overlapped float shows its ring where it peeks out and covers nothing, and in a Floating space the rings say which windows KiwiDesk manages — the reading the ring exists for. Overlays and fullscreen windows keep their exclusions, monocle stays focused-only. The float-tier raise keeps floats above a tiled plane, and read through the flag a floating-mode member is that plane: every member focus, and every switch onto the space, lifted a flagged or sticky float back over its siblings, an order the user could never keep. So the raise stands down after a focus that lands on an effective float and takes no floating-mode member as the floor its switch-time lift clears, while its targets stay the flag’s, because that space has no plane to lift over. The badge marks the exception to a space’s layout — one window that floats where the rest tile — and a floating-mode space has no exception to mark: every glyph would wear the layout’s own symbol and say nothing, so it keeps the flag, and the same-app grouping it breaks keeps it with it (owner ruling 2026-09-13). The ring and the badge do not “disagree” by this: one is a mark drawn, the other a ring withheld, and neither claims the space is anything but what the user set it to.

[Principle]

A space’s name is its identity; the PROFILE is the scope that name resolves in. Two profiles may each declare a space called 1, and they are different spaces holding different windows — but focus_space 1 still takes a bare name, resolved against the profile you are in, the way it already resolves against the Desktop you are on.

The alternative shapes were both worse. Giving a space an opaque identity and demoting the name to a label breaks focus_space 1, app rules, keybindings and every stored key, and hides identity from the user entirely. Making the pair (name, icon) the identity — considered because the icon also changes what you see — is worse still: changing an icon would SPLIT a space.

What made the icon look contradictory was not the icon. A space’s mode (space_modes) and its icon (space.icon) were both stored per profile while the space itself was global, so two profiles’ 1 shared windows while disagreeing about how to draw them. Giving the space the same scope as its own mode and icon is what removes the contradiction, and it needs no new concept. (#1230)

A profile switch restores that profile’s partitioning; it does not merge by name. Before #1230, ensureSpace matched the incoming profile’s spaces onto the live ones by name, and pruneSpaces forwarded the rest to the fallback — so switching profiles and back merged an arrangement away permanently. Measured 2026-09-04: a profile holding five windows in space 1 and three in space 3 came back with all eight in space 1 and space 3 empty.

Each profile now carries its own record of which windows its spaces held, filed when you switch away and restored when you return. Window ids only, never window state — about sixty integers across three profiles, written once per switch.

Its counterpart is deliberately NOT stored, and the reason is WHEN each record is authoritative rather than who owns the fact. A window’s Desktop is read from the compositor continuously, so a stored copy would be read while the thing it copies is still moving, and every disagreement is a window that vanishes or appears twice. The profile record has no such hazard — nothing outside KiwiDesk has an opinion about which of its spaces a window sits in. (#1230)

The fallback space is an explicit choice, not “whichever row is first”. When a profile switch drops a space, its windows need a home. Tying that to the first list row (the #75 interim rule) forces users to order spaces by system constraint instead of preference — and the redesign made the order user-owned (drag to reorder). So the rehome target is a dedicated per-profile reference (fallback_space, KiwiDesk.set_fallback_space), shown as a badge on the row; without one, the first-of-list rule still applies, so old profiles behave unchanged. Pull-to-first was considered and rejected: it would have made reordering silently change the fallback. (#68 §3.3, #75)

Deleting a space removes every reference it holds (pin, Main role, fallback, per-space overrides) — a leftover reference would silently resurrect the space on the next profile load. App rules survive by design: they’re global, and another profile may declare a space of the same name.

Live state is the single source of truth for which spaces exist; gui.json mirrors it, never the reverse. A deletion prunes the space from live immediately (windows rehome to the fallback), not only when a later profile load happens to drop it — otherwise the next save re-captured it from live and it reappeared. The sidecar’s spaces list is kept a faithful copy of live as of the last authoritative reconcile: every explicit prune — a load_profile (including a scripted Lua/CLI one) or an in-place edit — writes the live set back. Hardware-driven applies (monitor change, Desktop binding) never MIRROR, and since #1230 they prune exactly when they change the profile — re-applying the live one still shuffles nothing, which is what the no-shuffle-on-reconnect rule was protecting. So between such an event and the next reconcile the list may lag; the cold-boot seed and the next prune re-converge it. The one place gui.json seeds into live is cold boot — a space that lives only in the sidecar (no profile, pin, window, or set_mode backs it) is seeded so it survives the reload. That seed is safe against resurrecting a profile-pruned space precisely because the mirror keeps the list current. Deletion is per-profile: each profile is its own file, so removing a space from the active profile never touches another profile that still declares a space of the same name. (#77)

Desktop→profile bindings key to the main screen’s Desktop — and the separate-Spaces recommendation retired with that definition. KiwiDesk resolves one active profile across the whole display setup, so with macOS’s “Displays have separate Spaces” on — the macOS default — “Desktop N activates” needs one display to answer for it. #888 ruled that display to be the main one (the screen with the menu bar): a swipe on the main display selects profiles and a swipe on a secondary display never does. That is the PROFILE half, and it stands. The Space half moved in #1230: every Desktop keeps its own Space memory now, each screen’s included, and a secondary swipe moves that screen onto its own Desktop’s Space without touching the profile. Shared mode and a single display are degenerate cases — the main screen’s Desktop IS the global one — so their behavior is unchanged, and the precedent was already in the tree: the starter setup is “named by the main screen”.

This superseded #8’s recommendation to turn the option off, which was the previous answer to the same ambiguity. That advice was wrong-by-default twice over: every multi-display user met the degraded state out of the box, and following the advice forfeited real macOS ergonomics — each display’s own menu bar, the Dock summonable on any display, fullscreen on one screen not blanking the others, all of which exist only with separate Spaces ON. Shipping 1.0 with “change a macOS default” as standing advice and retracting it later would have been guidance churn.

Two alternatives were weighed and rejected. Coordinated switching (KiwiDesk switches all displays together so a global number stays well-defined) is drift-prone — one swipe on one display breaks the invariant, and force-resync teleports screens; it survives only as a possible later opt-in verb. Per-display active profiles (profile slicing) is a full redesign, parked on demonstrated demand. Main-display authority is deliberately the smallest ruling that removes the recommendation: a binding on a Desktop that lives on a secondary display simply never fires until a screen change makes that Desktop the main screen’s — honest, documented, and cheaper than either machinery. The identical-monitors ambiguity (#734) gets the same answer as its existing Monitors row — the main screen is unambiguous at the CoreGraphics level, so no new machinery. (#8, #888)

Profiles may override behavior settings, never routing ones. A profile owns tiling, and may also carry a sparse override of a global setting that shapes how KiwiDesk behaves while the profile is active — keybindings (Profile.layers) and the three window-rule families: app→space (Profile.appRules), float (Profile.floatRules), and ignore (Profile.ignoreRules). The global base lives in the active config owner (gui.json or hand-written init.lua). Each profile stores only additions and explicit tombstones; families resolve independently, then effective ignore remains the hard management gate. Thus an ignore tombstone exposes an app to its independently resolved app/float rules. It may never override a setting that selects or routes the profile itself: the Desktop→profile bindings decide which profile loads, so a profile owning part of that map would be a self-reference (load A → A rebinds Desktop 2 → B → …). The GUI language is a second hard exclusion for a different reason — it lives in UserDefaults, outside config ownership entirely, and must never touch a sidecar. Every override is the base overlaid with a sparse diff (absent inherits; a tombstone removes), never a second home for the setting. The binding rules for adding one — sparse-diff mechanics, parity tests, mutation through the KiwiCore facade — live in AGENTS.md §5.

Floating windows hide with their space; visible-everywhere is Sticky, an explicit flag. Historically a floating window was exempt from the inactive-space stash and followed you across spaces — the stash comment even blessed it as intended “for PIP”. #412 reclassified it as a bug: state always scoped the window to one space, only rendering disagreed, and a user who floats a scratchpad on space A does not expect it over space B. Now every window — tiled or floating — parks with its inactive space (the engine captures a floating window’s frame on first stash and restores it when the space returns; layouts recompute tiled frames anyway). The deliberate “present on every space” behavior is the per-window Sticky flag (#414, toggle_sticky) — fully managed, unlike the blunt ignore_rules gate. Consequence, accepted: a Picture-in-Picture panel that presents as a managed floating window now parks with its home space until marked sticky; most PIP/quick-terminal overlays are tracked as transient overlays or ignored outright and never stashed at all. “Sticky” is the settled user-facing term (tiling-WM lineage: X11 _NET_WM_STATE_STICKY, i3, yabai); “pin” was rejected — Apple’s own apps use pin for “fixed here”, the opposite direction. Sticky is per-instance state, never a rule list, never a profile key, and never stored by duplicating the id into other spaces’ arrays. (#412, #414) Because it is a coinage, it is kept verbatim in every locale (#579) — a Family A product name like “App Bar”/“Space Bar”, not translated to a native word for “pinned” (the display tier is “Display Sticky”); see docs/localization-naming.md.

Sticky has two scopes: global and display (#445). The original sticky is global — every space of every monitor. A second scope, display sticky, keeps a window on every space of one monitor only (its home space’s display), the common “keep this on my main screen, not the laptop” want. The scope is a per-window value (StickyScope.none/global/display), not a new flag — the home display is derived from the home space’s display, so a cross-display move re-homes it with no bookkeeping. Two peer verbs (make_display_sticky / toggle_display_sticky) sit beside the global ones; make_unsticky is shared, and each verb writes its scope outright so make_sticky on a display sticky turns it global and vice versa (the #221 sibling-verb model — no tri-state, no detection source). Both wear the same mark toggle and color; only the glyph differs — infinity (∞) for global, pin.fill (📌) for display (the pin reads as “tacked to this screen”, the sibling of SpaceAssignmentChip’s “bound to one space”). On a single monitor the two scopes coincide (stickyRenderSpace collapses display to global), so nothing changes for single-display users.

Because a sticky window’s whole point is to stay put, move_to_space on one is guarded rather than silently rewriting its home membership: a global sticky refuses any target (it is already everywhere), a display sticky refuses a same-display target but accepts a cross-display one (which re-homes it). The refusal reuses the shipped StickyMarkPlate pill (sticky.everywhere.pill / sticky.display.pill), fired from the shared moveWindow choke point so the keyboard move and the Space-Bar drag both honour it. Which display a sticky renders on is stickyRenderSpace: a global sticky follows the focused display (one physical window can only be one place), a display sticky follows its home monitor’s shown space — and its home space no longer reserves a phantom tiled slot when it has traveled away, which is what let the same window fight for two frames across monitors before. (#445)

A window on another screen belongs to that screen’s space, not to the one it came from. KiwiDesk notes the space a window was in when it vanishes from Accessibility — a Desktop switch, an app hidden with ⌘H — and files it back there when it returns, which is what makes a Desktop swipe non-destructive. Across screens that memory can be out of date by one deliberate gesture: move_to_desktop onto another screen’s Desktop (or the same drag in Mission Control) carries the window physically to that monitor, while the space it remembers is laid out on the one it left. Something has to lose, because the two answers put the window on different monitors. The Desktop the user just chose wins — it is the more recent intent, and it is the one they can see. Leaving the window filed under the old space means the next retile lays it out there and macOS re-assigns its Desktop to match the frame, so the move undoes itself about a second later.

One ruling, asked at two altitudes, because there are two routes to that same undo — and each altitude answers only its own. A Desktop the target screen is not showing takes the window out of KiwiDesk’s view entirely, so the answer is owed when it comes back, against the space that screen really shows by then; revealing a Desktop can activate a different space than the one showing when the move was issued, so answering early would file the window somewhere it cannot be seen. A Desktop that screen IS showing produces no departure at all, and there the answer is owed at once, by the verb. Measured both ways on two screens (2026-08-25): with only the arrival half, moving a window onto a Desktop the other screen already showed still snapped it back inside 0.6 s. So the predicate is one shared function and each caller gates itself to the route it owns.

Two alternatives lost. Keep the membership but suppress the cross-screen retile leaves the window unmanaged exactly where it landed — the beat reported as “it moved but it didn’t tile” made permanent. Refuse cross-screen moves outright removes the half of the verb multi-monitor users want it for. Both answer “which of KiwiDesk’s two models is right”; only the ruling above answers “what did the user just ask for”.

What it does not reach, each for its own reason. A window KiwiDesk never watched leave is untouched: a snapshot restore files windows it is not tracking yet, and that filing IS the layout the restore exists to put back — after an undock macOS piles windows onto the built-in screen, and following that frame would discard it. A floating window is untouched because the defect is the layout carrying a window home and a float is never laid out; its cross-display anchoring stays #444’s and #412’s. A sticky window of either scope is untouched because re-homing one is precisely the move stickyMoveRefused gates at every command choke point (#445), and neither a pure state fold nor a Desktop verb may make it quietly; sticky reach across Desktops is #890’s own item. And only the window’s membership ever moves — no space is re-assigned to another display — so an arrival or a Desktop move can never break a pin_space_to_display pin. On a single screen every one of these questions has the same answer it always had. (#1010)

The starter setup is chosen from the screens, not demonstrated on them. The first version of it (#466) gave every display the same five spaces — one per layout mode — so a newcomer met the whole range at once. That is a showroom, and it is the wrong reading of “approachable by default”: approachable means a setup you KEEP. So the layouts now come from each screen’s shape, in points (ScreenClass): a laptop under 1900 pt gets Scrolling and Monocle and never Track, which has no width to work in; a screen at or past 3000 pt — or past a 2.1 aspect, which catches a short ultrawide the width test misses — wants Track first among its tiled layouts, and BSP exists only in the middle class, producing absurd windows above it and unusable ones below. Points and not pixels, because a 5K 27” and a 1440p 27” both report 2560 pt and want the same answer, while a Retina laptop reports 1728 and wants laptop layouts despite having more pixels than either.

Spaces are budgeted, never added up per screen. A laptop’s three plus a 27“‘s five would be eight keys to learn on day one, most of them empty — so the total is 3 · 5 · 7 · 8 · 9 by screen count and then one more each, soft-capped at ten, with each screen’s share proportional to its width and clamped to 1…3. The cap is on spaces, so min-one-per-screen outranks it: eleven displays gets eleven spaces, because a screen with none has nowhere for a window to resolve to. Three rules ride on top — exactly one Floating space, on the largest screen that has room beside it; the lead ruled below; and no layout twice unless the budget forces it or the lead repeats it deliberately. The count tails off because screens four and five are almost always glanceable (logs, chat, a stream) and want one space that is always the same; the cost of a space is a key to bind and a name to recall, not screen area. And we run out of keys before we run out of spaces: past ⌥1–9 and ⌥0 there are no default go-to keys left.

Every screen opens in Scrolling but the smallest, which opens in Monocle. The shape rule above decides what a screen is good for; it does not decide what the user should be shown FIRST, and for a while it did both. Best-first meant a 2560 pt desktop led with Grid and an ultrawide with Track, so the first thing most new users saw was their windows cut into halves or thirds — the impression that makes people close a tiling manager on day one. Scrolling is the one mode where nothing is squashed: each window keeps a comfortable slot and the neighbours wait one keystroke away. So it leads, and the rest of each screen’s list follows behind it unchanged.

The exception is by SIZE, not by class and not by which screen is main: rank the screens by width and the narrowest leads Monocle, which is what a small screen is best at. A solo screen leads Scrolling whatever its size, so at least one Scrolling space always exists. Two consequences are deliberate and worth stating, because both look like bugs from close up. The rule is unconditional, so a 27” beside an ultrawide is “the smallest” and leads Monocle although its own class lists none — being smallest is a fact about the setup, and a per-class qualification would make the answer depend on two things instead of one. And Scrolling now leads several screens at once, which the no-layout-twice rule had to be carved out for explicitly: an accidental repeat is still a bug, this one is the feature.

A starter Scrolling slot is set, not left on auto. auto resolves near-full — one window with a sliver of the next — which reads as “my windows were squashed into one” rather than “the neighbours are one keystroke away”, and the mode’s whole argument is the second reading. Just under half the axis puts two windows side by side with the gap visible, which is the picture that teaches the mode; an ultrawide takes 30% for three readable columns, the case ScrollSize.auto already documented as wanting an explicit size. One profile-wide value and no per-space overrides: a first-run profile full of overrides is a second config the user has to understand before changing the first. (Owner ruling, 2026-08-25.)

One tuning per profile, and it is the main screen’s. This is not a preference — TilingSettings is profile-wide, so a laptop beside a 27” has exactly one gap value and one stack ratio to give, and the only question is which screen names them. Per-space overrides express the rest. Do not read the StarterTuning seam as a per-display one waiting to be built: making it one would put a second config behind every value the Settings window shows.

An unlisted mode in a sparse preset follows the screen it lands on, not a fixed bsp. The workflow presets predate the screen-shape theory and several declare a mode for only some of their spaces — Minimalist leaves space 2 unlisted, Focus Stack space 3 — with the rest resolving to bsp. Those are one-screen presets, so applying either on a laptop silently handed it the one layout ScreenClass rules out there: below 1900 pt a three-window BSP is already under the minimum in one axis. The unlisted mode now resolves to that screen’s own best layout. Where the hardware genuinely is not knowable — a preset card draws a plan for a screen COUNT, and a three-screen preset is drawn on a one-screen Mac — the historic bsp stands, because inventing a shape is a worse answer than the old one. (Owner ruling, 2026-08-11.)

There is one Starter preset, and it is for the screens you have. There used to be three, one per screen count, because the ladder planned for a count in the abstract. A setup derived from screen shapes cannot answer “which two screens?”, so a count you are not running offers the workflow layouts alone — that is what “For other setups” now means. The preset’s summary loses the sentences that named its rungs for the same reason: a list of modes would be a different sentence on every Mac, so it states the rule and the thumbnails show the modes.

[Principle]

Sticky promises “always with me”, and before #1145 that promise stopped at the edge of KiwiDesk’s own Spaces: a Desktop switch left the ∞ window behind. Where the window-management bridge exists, both scopes now follow the user across macOS Desktops. The rulings:

  • Reach is a carry, never a membership. macOS applies no second Desktop membership for a foreign window — the bridge’s ADD reports performed and changes nothing (.claude/rules/os-private-apis.md carries the probe), and a version built on it shipped and was reverted (#1205/#1206). So reach is a MOVE: at every Desktop switch KiwiDesk moves each enabled sticky window onto the arriving Desktop of its own screen, eagerly from the switch and again at the settle. Same promise to the user — switch, and the window is already there; the one visible difference is Mission Control, which shows a sticky window on one Desktop at a time.
  • Both scopes carry within the screen they are shown on. ∞ and 📌 differ in which KiwiDesk Spaces a window follows; across Desktops they behave alike — each follows the Desktop switches of the screen KiwiDesk draws it on (∞ the active space’s, 📌 its home’s, the #445 render rule) and never jumps screens because the OTHER screen switched. Without that a two-screen ∞ window would ping-pong between screens on every swipe. A screen showing a fullscreen app or a system space is no carry target, and a native-fullscreen window is never carried; its next user Desktop is.
  • One toggle covers both scopes (sticky.desktop_reach, default ON). A per-scope pair was deferred on the evidence rule: nobody has asked for ”∞ across Desktops but 📌 not”, and the config reshape stays cheap if someone does. Default ON because reach is what the sticky glyphs already promise — a window marked “always with me” that a Desktop switch strands is the surprise, not the reach.
  • A single window can be pinned against the toggle (override_sticky_reach on/off/auto — the make_auto semantics: auto clears the pin back to the toggle). Session-scoped: a pin is a judgement about this window now, and old window ids get recycled.
  • The row hides without the bridgean absent capability is not a greyed one (its own entry) applies verbatim: no setting or mode reaches the capability, so a grey would invite an action with no path.
  • Nothing to undo at quit, and nothing to keep. A carry leaves no membership behind: a sticky window simply stays on whichever Desktop it was last carried to — the one the user was on — so a crash or force-quit strands nothing, and there is no ledger whose loss could. What the model gives up is the spontaneous case: a sticky window sitting on a Desktop the user is NOT on when reach is switched on is fetched by the toggle itself and by the next switch, never on its own.
  • A carried window’s vanish is expected, and sticky’s promise wins the tie. For the switch transition’s beat a carried window is on no reading KiwiDesk has — and neither is a window that was just closed. The sweep refuses a carried window’s vanish for a bounded budget while the carry holds it in flight rather than dropping its slot, scope and pin (the mechanism is .claude/rules/accessibility.md’s). A switch KiwiDesk itself dispatches promises that flight at dispatch, before the OS says a word (#1213); a gesture switch has no dispatch of ours to promise from, and the trade is an accepted limitation, stated there.

[Principle]

Leaving a macOS Desktop makes every window on it vanish from Accessibility, and KiwiDesk’s reconcile folds each vanish as a destroy — the same fold a close takes, because at that altitude the two are the same reading. A close hands focus to the next window, so a Desktop departure walks Space.focused down the departing windows to nil, and on the return the first window to re-list took the empty slot: focus jumped to first-in-row, and a scrolling layout panned to it, at the moment macOS had just restored the window you actually left (#1207).

The ruling is that the departure is not a close and the return owes the user the window they left, the way a follow owes its window (#1007’s principle: an operation that names a window owes the window, never a bookkeeping entry about it). The choices, each argued against its alternative:

  • The focus is remembered at the focus REPORT, per space — never at the departure. The first cut wrote the memory in the switch handler, on the premise that the switch notification precedes the reconcile burst (#40). The device disproved it on the first eyeball: an app whose own Accessibility observer reports fast folds its windows as destroys before the notification arrives, so the handler read a focus the walk had already moved and the return paid the wrong window — over the very focus macOS had just restored. So the memory is written where the fact is born, at every honored focus report, under the space the window sits in and the native Space the WindowServer hosts it on — the compositor’s answer, which no notification’s timing can stale; nothing a departure does can reach it, and a return owes only a window that is actually gone — one macOS restored and KiwiDesk already honored needs no payment.
  • The debt is paid at the owed window’s own ARRIVAL, never at the settle. The jump happens at the switch, when the first re-track claims the vacancy; the 600 ms settle is a second leg that used to assert the wrong pick, and it runs before a slow app re-lists at all (TextEdit re-lists about a second after the switch). So the create fold takes the owed window mirrored in and does two things: the owed window takes the focus when it returns — even beside a window the walk landed on — and while it is still away no other returning window may take the vacancy it left. The settle stands its refocus down while the debt is unpaid. Paying is a raise with the settle’s own shape, so the state’s pick becomes macOS’s, and the arrival’s own retile pans a scrolling viewport to it.
  • The walk is kept. Not walking Space.focused on a Desktop departure was the other option and is rejected: focused must always name a member, and the walk is harmless once the memory outranks it. That is also what keeps a carried sticky window (sticky reach) from being preferred: it never departed, so it is never owed anything and holds no vacancy — there is no fallback pick to prefer it with.
  • The row comes back as it left. A Desktop departure erases slot order too, and re-track order is per-app and arbitrary, so the same fix carries each departed window’s slot and re-inserts a return by rank against the members already back — by rank rather than at the index, because with only the last window back an index insert would put an earlier one behind it.
  • One debt at a time, and the follow’s first. A debt lives from one return to the next: the arrival arm retires the last one before deciding whether to owe again, because passing through a Desktop before its window re-lists must not hold the next Desktop’s vacancy and settle on a window that cannot arrive there. And a focus the OS or the user lands on a window of the arriving space while the debt stands retires it: that report is the truth the memory was only standing in for, and the owed window’s later arrival must not pay over it. And a return owes nothing while a move_to_desktop_and_follow is owed: the verb named its window, and paying both on one arrival would let whichever re-lists last win — the explicit verb losing to a restore.

The debt is bounded at five seconds, like the follow’s; a window that never re-lists within it leaves macOS’s own restored focus standing, which is the accepted limitation, as is the secondary screen’s Desktop return, whose switch arm has neither the departing Space nor the previous Desktop number in hand. ReturningFocusFoldTests pins the fold, DesktopFocusMemoryTests and DesktopFocusPaymentTests the remember→owe→pay path through the real handlers, and ReturningFocusSeamTests the wirings — the recorder at the honored report and nowhere in the switch handler, the settle’s stand-down, the payer’s raise.

A window on an away Desktop is known, not gone (#1146)

Section titled “A window on an away Desktop is known, not gone (#1146)”

[Principle]

KiwiDesk used to know only the windows it could see. A window on a macOS Desktop no screen was showing was evicted from state at the switch, and everything downstream guessed: the gone reason was a timer that read the previous switch for a fast app’s departure (#40, falsified by #1207’s trace), and Open or Focus un-parked a local window beside one that was up one Desktop away (#673’s accepted residue). The WindowServer knows all of it — one private list per Desktop answers which windows a Desktop hosts and whether each is up or parked, in well under a millisecond per Desktop (the measurement sits on the primitive’s docstring, SkyLight+WindowCensus.swift) — so the ruling is that a window on an away Desktop is known, and the choices, each argued against its alternative:

  • A ledger beside the state, never members in it. The visible-only state stays exactly what #1207 left it — departed windows evicted, remembered by space and rank — and a compositor-confirmed vanished writes one entry beside it (pid, app, bundle id, native Space). Keeping away windows as members with a flag was the alternative: every consumer of a space’s row would gain an exclusion, the return-by-rank fold would be reworked, and the removal-distrust machine would need a third state. The ledger reaches its consumers explicitly — the gone classifier, the Space Bar, Open or Focus, get_state — and nothing else changes.

  • The gone reason reads the compositor, and the timer is the fallback, not a peer. Hosted on a user Desktop nobody shows is vanished; hosted nowhere is closed; hosted on a shown Desktop while the app no longer lists it is closed too — a teardown or an under-report, either way not one gesture away. The settle timer decides only where SkyLight cannot answer, so a Mac without the symbol keeps the pre-#1146 behavior rather than a fake. And “gone” is the space list is empty, never absence from CGWindowListCopyWindowInfo(.optionAll) — why not is on AXHelper.allNormalWindowOwners’ docstring.

  • A window that dies while away gets its corrective closed. The ledger is re-read against one census at the Desktop settle and every five seconds while it is non-empty; an entry the WindowServer no longer hosts is reported closed, a second destroy for an id that already sent vanished. The documented consumer pattern — events as dirty flags plus a re-query — already tolerates it, and a consumer filtering on closed was otherwise never told at all.

  • The Space Bar draws the Desktop in front of you — away windows are absent. There are two kinds of “not on screen right now”, and the bar owns only one: a window KiwiDesk parked (it is in Space 2 while Space 1 shows) is KiwiDesk’s to draw and always has been, while a window sitting on another macOS Desktop is macOS’s business. That is exactly the windows / awayWindows split, so the bar reads the visible state alone. Hide empty Spaces hides a Space holding only away windows.

    Drawing them was tried first (#1146) on the reasoning that a Space holds its windows wherever macOS is showing them. On device that reads as a lie: the bar is always on screen, so it is taken as a picture of here, and a glyph identical to a present one makes a click’s consequence — a Desktop switch — invisible. Marking them instead was refused on its own terms: a dim tier already means unfocused in the bar, and any new mark needs a legend. The knowledge is not wasted — Open or Focus still reaches an away window, and the place to see everything is an overview panel summoned deliberately, not a strip that is always there (#1228).

  • Open or Focus reaches an away window over the bridge, and owes it the focus the way a follow does. Nothing up on a shown Desktop but a window up on an away one is a Desktop switch plus a debt paid at the window’s arrival (#1007’s shape, the same FollowFocusIntent), never an un-park and never a duplicate launch; the cycle ring holds each Space’s row with its away windows in rank order, so the key walks what the bar shows. Without the bridge the branch stands down and activate() runs, as before — a faked switch was refused under the bridge is not a SIP escape hatch.

  • Boot records what it can attribute, unfiled where it must. A window UP on an away Desktop at boot is filed under the session snapshot’s space, else the Desktop’s remembered Space, else recorded with no Space at all — known to the classifier and to Open or Focus, filed at its reveal through the newcomer rules. Dropping it until shown was the alternative, and it would have kept the cold-boot duplicate launch the issue names.

    A parked window is not recorded at all (#1234), which is the runtime’s own rule applied at boot: every reader of the ledger requires the window to be up, so a parked entry serves nobody, and “minimized while away is not reachable” is already this feature’s accepted residue. Recording one could only leak — nothing tracks such a window, so no return ends its entry, and the compositor still hosts it, so no prune does either. An immortal entry is not merely untidy: the ledger’s five-second census re-arms while it is non-empty, so a handful of them poll the compositor for the life of the process.

  • The sweep keeps its one-way trust. The on-screen census may refuse a removal, never cause one (#1157), and the per-Desktop census is downstream of that decision: it classifies and files what the sweep already removed; the one arm that reaches the compositor from the sweep, the fullscreen arm (#1272), may only refuse a removal, never cause one. A carried sticky window (#1145) is present, never in the ledger.

What this deliberately does not do: make a Desktop return faster. The windows are still re-adopted through Accessibility at the app’s own pace; what changes is what KiwiDesk knows while they are away. Clicking a Space whose windows are all away still switches KiwiDesk’s Space and not the Desktop holding them — that coordination is #1148’s.

A Desktop move’s explicit Space is paid at the departure (#1150)

Section titled “A Desktop move’s explicit Space is paid at the departure (#1150)”

[Rationale]

move_to_desktop(3, "mail") names where the window should be when it gets there, and a window sent to a hidden Desktop is not there yet: macOS shows another Desktop’s windows to nobody, so the window leaves KiwiDesk’s state at the move and joins it again at the reveal, through the create fold’s ordinary rules. Writing the membership eagerly at the command would put a record in front of that fold — the arrival would then find a member it was about to file, and every reconcile between the move and the reveal would be reconciling a window no screen shows (the arrival-semantics ruling on #890). So the name is a pending assignment: recorded at the command (PendingSpaceAssignment), paid at the window’s DEPARTURE, where the destroy fold has just written the Space it left as its remembered Space and the name replaces that record (redirectDeparture). The arrival then needs nothing new — the remembered-space rule lands the window where the user said. A Desktop its screen already shows produces no departure, so that route files the window at once, the way move_to_space does; the two routes split on the same isCurrent gate the cross-screen re-home splits on, and the explicit name outranks that re-home, since the user named the destination.

A Space assigned to another screen than the Desktop’s is refused rather than honored: the layout carries a window to its Space’s screen, and macOS re-assigns the window’s Desktop to match its frame, so honoring it would undo the move within a second — the #1010 defect, asked for by name. A Space no screen owns yet, a fresh one included, has no settled screen while more than one is connected — the layout falls back to the screen holding the key window, the placement resolve to the menu bar’s, and the two disagree exactly when the user is working on the secondary screen — so it is refused there, with the pin hint, and accepted on one screen, where every reading agrees. It is deliberately not assigned to the Desktop’s screen on the user’s behalf: a runtime assignment outside the pins is undone by the next placement resolve (a profile apply, a monitor change), after which the window sits on a secondary screen’s Desktop while its Space lays out elsewhere, the same undo a beat later. A parse writes nothing, so a refused move leaves no empty Space behind. And the explicit Space is a membership write where a bare Desktop move is not, so it takes the one sticky gate move_to_space takes, with that gate’s own asymmetry (#445: a global sticky refuses any Space, a display sticky refuses a same-screen one and re-homes across screens) — told where the Space will lay out, since an unowned one reads as “elsewhere” to it — and where it refuses, the whole command is refused rather than half of it done. The remembered slot goes with the re-filing: a rank means something only in the Space it was taken in, so a window filed into a different Space returns by the arrival’s ordinary placement rather than at its old index (#1207).

A Desktop switch that changes nothing still reports itself (#1336)

Section titled “A Desktop switch that changes nothing still reports itself (#1336)”

[Principle]

A command that acts on nothing answers success carrying the fact, never a bare .ok() and never a refusal. focus_desktop and move_to_desktop_and_follow both answer {"switched": true|false}, with a note on the stand-down.

The measurement: on the device, move_to_desktop_and_follow 1 while Desktop 1 was already showing printed nothing, logged nothing and exited 0. A caller cannot tell that from a switch that moved the screen, and the user has no signal the verb declined — which is the half of #1336 the reporter called the worse one.

Success rather than a refusal, because these verbs are routinely called to ENSURE a state: “make sure Desktop 3 is showing” must not fail merely because it already was. That is what separates this class from the sibling ruling above — a keyboard reorder that cannot apply refuses with the home-space pill, because there the user asked for a CHANGE and the arrangement cannot give it. Asking for a state you are already in is not a failed change.

Structure rather than prose, because the payload is read by scripts on three channels. It also decides the shape of the other arm: were only the stand-down to carry data, the Lua return would be truthy exactly when nothing happened and nil when the screen moved. Both arms report switched, so the discriminator is a field rather than the payload’s presence.

switched describes the SWITCH, not the command. A follow’s stand-down arm is the one that does the most work — it is the only path on which the cross-screen re-home fires — so switched: false there must not be read as “nothing happened”. The window moved; only the screen did not.

Two residues, deliberately left. The plain move_to_desktop is NOT covered: whether the window was already on that Desktop is not knowable from a target current on its own screen (the moveToDesktop docstring rules this), so there is no detectable no-op to report — its silence is about the WINDOW where this ruling is about the SCREEN. And the hotkey path discards command responses entirely, so a keyboard user still sees nothing; a screen that did not move is its own evidence there, and giving it a cue is a separate question from this one.

A ∞ window entering a floating Space on another screen is moved, not left (#1217)

Section titled “A ∞ window entering a floating Space on another screen is moved, not left (#1217)”

[Rationale]

A globally sticky window renders on the space you focus (#445), and on a tiled space that means the layout places it — on whichever screen the space lives. A floating-mode space places nothing, so a ∞ window entering one on another screen kept the frame its previous space drew, physically on the old screen while the Space Bar already listed it on the new one (#1217). That is a correction that places a window nothing else will — the definition of a float net (#1178) — so it runs from the retile and asks the one float predicate with the render space as the space the window is judged on. The frame it takes is the one it last had, moved onto the target screen proportionally through the same re-anchor a floating window gets when it crosses screens, honouring the same scale setting; a floating space on the same screen moves nothing, and a tiled target keeps today’s layout placement. The frame is transient by ruling: the next tiled space on that screen re-tiles the window, and a remembered per-window float frame is a separate decision, ruled only if the transience shows on a device.

“Apple-native” binds behavior, not the Settings GUI’s visual idiom

Section titled ““Apple-native” binds behavior, not the Settings GUI’s visual idiom”

[Principle]

“Apple-native feeling” applies to how the software behaves and interacts as a whole — never as a requirement that the Settings GUI copy System Settings’ visual idiom. Behavior stays native: standard controls that work the standard way, system conventions for focus, keyboard, VoiceOver, dark mode, drag and drop. The Settings window’s information architecture and look are KiwiDesk’s own — the redesign’s Home card grid and non-sidebar navigation (#678) deliberately break with System Settings, and System Settings is explicitly not the bar to clear.

The reason is what the old reading cost: System Settings’ sidebar idiom fits an OS exposing hundreds of unrelated panes, and copying it forced KiwiDesk’s ~12 related areas into a shape built for a different problem — while the things users actually struggled with (where a setting lives, which rows matter in week one) are IA problems the borrowed idiom cannot fix. Simplicity and intuitiveness stay first, unchanged, and still break ties; what changed is that “would Apple draw it this way” no longer vetoes a layout that is simpler for this app’s shape.

What breaks if this is ignored: a reviewer holding a redesign screen against System Settings’ visual conventions rejects exactly the improvements the redesign exists for, or — the inverse failure — someone reads “GUI ours” as licence for non-standard controls, which is the half that stays bound. (Owner ruling 2026-08-02, in chat; first applied in the Phase 2 Bars area.)

The Settings window paints its own colours, and its accent is kiwi

Section titled “The Settings window paints its own colours, and its accent is kiwi”

[Principle]

Every surface, border and ink in the Settings window comes from one token table (SettingsTheme), and the window tints its controls with KiwiDesk green rather than the user’s system accent. Two halves, one argument.

One table. Before it, the header was a .bar vibrancy material, the cards controlBackgroundColor, the hairlines Color.primary.opacity(0.12) — three neighbouring greys from three unrelated systems, none of which moved when another did, and one of which (the vibrancy) took its colour from whatever window happened to sit behind KiwiDesk. That last is why the shipped shell read as the wrong colour: the app had not chosen it. A token also has a dark counterpart by construction, which an opacity wash over an unknown backdrop can never have — the paused banner’s amber could not be given one at all while it was .orange.opacity(0.12).

Kiwi accent. A user who sets a pink system accent loses it inside this one window, and that is the price. What it buys is that the window looks like KiwiDesk rather than like a generic form, which is the same argument as the entry above: the IA and the look are ours. The accent is also identical in light and dark, because brand recognisability is the thing it carries — the same reason the tokens that paint pictures (the desktop plate, the keyboard board’s fills) hold one value in both modes: what a picture shows must not change with the window’s appearance.

What breaks if this is ignored: a second hex literal beside a view, which is invisible until the day the palette moves and one surface stays behind — and, for the accent, a window that is half kiwi (the chrome KiwiDesk draws) and half whatever the user set (the native controls), which reads as unfinished rather than as respectful. (Owner rulings 2026-08-04, in chat.)

Usable without a mouse is a second claim, and a dim is not a sentence

Section titled “Usable without a mouse is a second claim, and a dim is not a sentence”

[Principle]

“Accessible with VoiceOver” and “usable without a mouse” are two claims, and this tree shipped the first for a long time believing it had both. Two rules fall out, and they are the ones a Settings change keeps paying.

A shape change states where focus goes. When the view holding focus stops existing — a deleted row, a pushed sub-view — nothing claims it and the next Tab starts from the top of the window, so a keyboard user re-walks the list after every deletion. The destination is read BEFORE the mutation (afterwards the list names whichever row slid into the gap, right by accident and wrong at the end of a list), and it must be a control that is always DRAWN and non-destructive: the first cut of this bound the spaces list to a mode-gated button, which on a fresh install is not drawn at all, so focus went to the top by a second road.

A dim is not a sentence. Greying keeps a control visible because the dimming means switch that on and I act — so a greyed control that announces only “dimmed” tells the reader an answer exists and withholds it, which is worse than one that was never gated. The reason therefore travels by one of three channels, in the order a reader meets them:

  1. a block gate keeps a live ? anchor outside the dimmed subtree (#527);
  2. a row whose cause is legible on its own surface — the gating control in the same container, or a standing caption that already names it — needs nothing more, and the hover string stays for the pointer user;
  3. a row gated from another destination takes the live ? whose sentence names where to go (Advanced Colours is entirely this class);

and what falls through all three — same page, no adjacency, nothing else to look at — draws the reason INLINE, outside the dim. Which rows those are is derived from the census rather than listed, because a hand-kept register of who owes a sentence is one more thing to forget: GateReasonPlacement answers it, and it reproduces every site that already drew one, which is what makes it checkable.

Amended 2026-09-14 (#1310, owner ruling on the device): channel 3 has two shapes, and which one a row takes follows from what is dimmed, not from whether a live label exists. A header ? scopes the CARD: when the whole card is grey (Borders with the ring off) the reader’s eye lands on the heading and the glyph obviously belongs to the grey beneath it. When ONE row is greyed inside a card whose other rows are live, the same ? two rows up reads as “about this card” and nothing points it at the row — the owner looked at the Space Bar colours card with a ? on its heading and asked whether that was understandable, and it was not. Such a row takes a CrossReferenceRow directly beneath the grid, outside the dimmed subtree: the sentence states the fact and the destination name is the link, so the fix is one click rather than a popover that then says to go elsewhere (AdvancedColorsHelp.focusedItemReference). The Borders card was the same shape one card up — Unfocused windows greyed alone while the ring is on, its reason on the heading — and took the same link (unfocusedReference); its header ? now answers only for the ring being off, when every row is grey. The header ? stays the shape for a greyed block.

The temptation to answer all of this with .accessibilityHint is why the ladder is written down. A hint on a leaf control is ordinary and two rows ship one. A hint on GreyOut is not: that modifier wraps whole blocks, so whether it reaches the controls inside — and whether its empty value in the un-gated state displaces a hint a descendant sets for itself — cannot be observed headlessly, and it was written and backed out for exactly that reason. Re-adding it needs a recorded Accessibility Inspector session, not an argument.

What breaks if this is ignored: the window keeps passing every accessibility guard in the suite while being unusable from the keyboard, because both failures are silent — an unattached @FocusState compiles and moves focus nowhere, and a reason in a tooltip is invisible to everything except a pointer. Stated residue, so it is not mistaken for coverage: for the co-located class the VoiceOver reader hears the cause before the dimmed row but must infer the link, and macOS gates keyboard focus for non-text controls behind System Settings ▸ Keyboard ▸ Keyboard navigation, which no app may set for the user — so a focus destination is verified with that ON. (#678 turn 20a, #815, #816.)

A name replaces the announcement, so the value comes back with it

Section titled “A name replaces the announcement, so the value comes back with it”

[Principle]

Every accessibility guard in the suite pins a DECLARATION; none can hear what VoiceOver ANNOUNCES, and the two come apart by three mechanisms (#812). An .accessibilityLabel REPLACES what SwiftUI derived — and for a Picker or Menu the derived announcement IS the selection, so the modifier that names the control is the one that silences its choice. A modifier’s side-promise is a device fact, not a docstring’s: labelsHidden() on a .menu picker drops the AX title too (macOS 26, 2026-08-24), against a docstring that said “visually only”. And a custom-drawn control has nothing free — a slider delegating to an unlabelled native representation announced a percentage of range for a 6 pt gap, because a row’s label and readout are SIBLINGS and a sibling Text names nothing, and it held no keyboard focus either. A picture read per mark is the same failure at a larger size: the keyboard board was a bare glyph per key, every state living in a fill or a ring.

The rule that comes out is one sentence: a control that is named is valued in the same change, and a picture speaks as one description. Naming and valuing is the control’s own — a SettingsSlider takes both as required arguments so the compiler holds it, a DropdownRow takes the selected option’s title from the site that knows it, and AnnouncedValueTests scans every Picker/Menu chain that carries a label for the value beside it, with an exact census of who is labelled so the scan cannot go quiet. The label a row draws beside a self-naming control is hidden, or the words arrive twice. And a picture — a schematic, the keyboard board — is ONE element whose label is its meaning, read from the same predicates the drawing reads (KeyboardBoardSpoken reads KeyboardCensus.state and overwrittenReserved, exactly as the caps do), never one element per mark: a stop per key is the picture’s pixels transcribed, not its sense.

Two rulings ride along. The save pill announces once, as it appears, and never a count — native macOS narrates no dirty state, so a sentence per change is noise, and the count is one cursor move to the pill away; the announcement is delayed so it is not dropped behind the changed control’s own value, which is what happened under keyboard stepping. And a headings rotor is structure, not decoration: every section title, group header and panel header carries .isHeader, because a reader who cannot glance navigates card to card, and Home’s two group labels had been the only headings in the app.

What breaks if this is ignored: the next control is named for search or for a census guard, passes every scan, and takes its own value away from exactly the reader the name was for. The residue, stated: nothing headless can hear an announcement, so a change here is verified on a device with VoiceOver on, and the guards say only that the declarations are present.

The row menu’s keyboard route is a chord on the focused row

Section titled “The row menu’s keyboard route is a chord on the focused row”

[Trade-off]

Context-menu-only actions get their plain-keyboard route from a chord on the focused row — invisible chrome, documented key — rather than a visible trigger. The alternatives were each rejected on grounds that still hold, and are re-litigable in both directions, which is why this is written down: a visible per row was rejected twice as clutter (owner 2026-08-04, upheld 2026-08-11 against turn 20a’s ask), a whole-chip Menu eats the .draggable it would sit on (it shipped on the assignment chip and silently retired the drag), and accept-and-document leaves a Tab-only keyboard user locked out of actions the app offers everyone else — the gap #845 was filed on, once it was seen that .accessibilityActions reaches only a running VoiceOver. The accepted cost is discoverability: an invisible key is found in documentation (docs/user-guide.md ▸ Using Settings from the Keyboard) or not at all, and an in-app hint is a candidate follow-up rather than part of the ruling.

Two consequences bind every row menu, not just the ones that prompted it. The chord must target the row that HOLDS focus — per-row window-wide shortcut registrations resolve by hierarchy order, which sent the chord to the first row on the page whatever was focused, cross-targeting destructive items (#845 review blocker) — so the seam gates the binding on the focused row’s published identity, one live binding at a time. And a row in the family must be able to hold focus at all, which is a separate claim from offering the menu: the assignment chip offered every channel and had no Tab stop until it took .focusable(). The seam, its guard and the engineering obligations live in .claude/rules/gui.md ▸ the keyboard path; the chord itself is stated once in code (ContextShortcut.swift, needled) and once for users (the guide). (#845; owner ruling 2026-08-23, recorded on the issue.)

A focus ring is the platform’s; a chip that removes it draws its own

Section titled “A focus ring is the platform’s; a chip that removes it draws its own”

[Trade-off]

The Settings window’s text fields keep macOS’s focus ring, in the user’s system accent, while the header’s search chip draws a kiwi outline of its own — and that difference is deliberate, not a surface someone forgot to convert.

It reads at first like the defect the entry above describes: one window, two focus colours, the green one on the chip and the system accent (blue on a default Mac) on every field. AppKit rings a standard TextField with NSColor.keyboardFocusIndicatorColor, which follows System Settings and is unaffected by .tint — the same class as Color.accentColor, which #678 turn 16b retired for exactly that reason.

Three answers were weighed. Convert the sixteen fields: each takes .textFieldStyle(.plain) to lose the platform ring, then re-earns a focus indicator by hand, then needs its own contrast pairing against its own ground, and the pairing wants a seal plus a guard the way settingsActionButton() pairs a style with its ink — and every future field pays it again. Give the chip the platform ring: not free either, because the ring arrives with AppKit’s bezel, so keeping the chip shape means macOS draws no ring at all, and the header loses the one thing that makes the search read as the same kind of object as the back chip and the profile chip. Rule the difference deliberate: nothing to build and nothing new to own.

The third is the ruling, and the north star is why: Apple-native binds behavior while the window’s look is KiwiDesk’s own. A focus ring is behavior — it follows the user’s accent AND their “Increase contrast” and focus-ring accessibility settings, none of which an app should answer for them. So the fields keep the platform’s, and the chip is not an exception to that rule but a control that never had a platform ring to keep: .plain removed the bezel for the chip’s shape, and a control that removes the platform’s focus indicator owes one of its own.

What breaks if this is ignored: someone “fixes” the inconsistency in the cheap direction and converts a field to .plain without replacing what it removed, which is a field that shows no focus at all — worse than either colour. And a custom indicator owes the contrast the platform’s had: the search field’s accent at 0.55 measured 1.52:1 on sunken and had to go to full strength, which is the floor any second one starts from. (#833, owner ruling 2026-08-12.)

The save pill counts what the header claims (#1197)

Section titled “The save pill counts what the header claims (#1197)”

[Rationale]

The pill’s leading readout is the ROW COUNT of the list it opens (owner 2026-08-10), so every reason the pill appears must be a row in that list. Two reasons exist: a draft config leaf, which SettingsDraftDiff attributes to a census row, and live drift — the active profile storing no monitor set for the connected screens, a built-in layout composing, a matched profile deleted — which is model state, not a leaf. A pill summoned by the second while its list was built from the first alone said Unsaved changes over nothing, and the header, deriving the same fact on its own, said Unsaved monitor changes beside it.

The ruling is one verdict, three readers, not three predicates: SettingsModel.profileDrift decides whether and how the live target drifts, and the header’s status line, the pill’s presence and the pill’s drift rows all switch on it. Extending the diff source (the owner’s option 1) rather than rewording the zero-row branch keeps the count honest and gives each drift an anchor — the Monitors fingerprints row for a screen setup the profile lacks, the Profiles root for the two profile-shaped arms. The alternative, an honest sentence for drift that cannot be itemised (option 2), was reserved for a residue that did not materialise.

A drift row says what is missing and names the button that fixes it, in its OWN words: the header’s sentences were borrowed at first and read as “Profile · Built-in layout — save as a profile to make it yours” (owner, 2026-09-03), because a header narrates a status line while a row narrates a change beside a label. The button is interpolated rather than quoted, so the row names what is actually on screen.

Two consequences are deliberate. A stored profile’s draft carries no drift row and summons no pill for drift: the header hides divergence while a profile is on the table, because the drift belongs to the live target, not the file being edited. And the profile-shaped rows jump to the Profiles ROOT rather than a control: nothing renders the composing Standard, and the Saved profiles card’s one anchored control is Load — the verb that replaces a layout — which is the wrong thing to flash under a note that says save as a profile.

The header search is a field, not a button that opens one

Section titled “The header search is a field, not a button that opens one”

[Trade-off]

The search entry in the header is the real text field, and its results hang below it as an overlay rather than in a popover.

It shipped the other way round: a field-shaped button that opened a popover containing the actual field and the results. That was defensible while the button was a small pill — a popover takes the key window for free, so focus and dismissal come from AppKit — and it became indefensible the moment the field grew to the full width the design calls for. A search field you click and cannot type into is a lie about what the control is, and the second field appearing on top of the first is the user’s evidence for it.

The trade-off is that a popover cannot be used for the results either: it would take the key window away from the header field on the first keystroke that produced a result, which is precisely why the field lived inside the popover before. So the results are a plain overlay, and the shell must lift the header’s zIndex above the content below it or the list is drawn over. That lift looks like a cosmetic line and is load-bearing.

What breaks if this is ignored: someone “simplifies” the overlay back into a popover and the field stops accepting the second keystroke — or removes the zIndex and the results become invisible while every test still passes, since nothing about paint order is observable from the view tree. (Owner report 2026-08-04, in chat.)

Search: one result per setting, and a fast path kept pure

Section titled “Search: one result per setting, and a fast path kept pure”

[Principle]

A search result is a SETTING, never an instance of one (#678 turn 11). The index holds one static row per census SettingKey — the same fixed list whether the user has three spaces or thirty, plus the catalog-only anchors (mode tabs, drawer titles) the census structurally cannot carry. A keybinding family is one setting and one result; a per-space override row is an instance. Indexing instances re-introduces the wall of rows the old one-per-destination cap existed to prevent, only data-driven and unbounded — and indexing values (“0.70” finding the space that overrides to it) is the same mistake through the back door. The things a user NAMES — spaces, profiles, palettes, app rules — belong in the Made by you group: findable by name, capped at five, one entry per object.

That group is named by ownership, not by location. It holds a space, a profile and an app rule, and none of them is a location — so a location word makes every locale translate a metaphor only English carries, and the literal rendering collides: French’s word for a tiling slot, Chinese’s word for the “Position” setting label on rows this same search indexes, which would stand a group caption directly above result rows using that word for something else. “Items” is no way out either: item already names a bar entry in this app’s vocabulary, and its Romance renderings collide the same way. What the group has in common is not where the things are but who made them, and that translates everywhere because it is a fact rather than a figure of speech. The wire name stays place (search.places, SettingsSearchPlace): in code the thing is a jump target, which is a place in the sense the caption could not use, and renaming the concept buys nothing a reader of the UI can see.

The match path is pure, and enrichment is a second phase. Matching is a synchronous substring scan over rows built once per locale: label (localized, through SettingsCensusLabel, so search and the diff rows cannot name one row two ways), destination title, and a sparse English synonym table that is match-only and never displayed — which is why it needs no translation. Everything else a result row shows — the current value, the mode pill — is computed per rendered row after the list paints, from the draft in memory. Nothing on either path touches AX, the filesystem or the running session (SettingsSearchIndexTests scans the match-path files for the violation; enrichment reads only the draft the shell hands it).

That line is why palette names joined the group last (#805): PaletteStore is stateless and file-backed by design — a palette saved anywhere is seen everywhere with no cache to invalidate — so listing them from the store would put a disk read on every keystroke. The palettes live on the model instead (SettingsModel.userPalettes) — the one in-memory copy, which the shelf already needed and now shares with search — written at the shelf’s mutations and at the window’s reload and never lazily from the search path; the store keeps its contract, and PaletteCacheTests holds the reader by construction and the writers.

The pill is the only place the mode is mentioned. Search indexes both modes, always. A result whose area the current mode withholds carries a quiet “Power User” tag — derived from the one offer predicate (HomeCardOrder.isOffered), never a hand-negated copy, so the Monitors display-count promotion silences its pill exactly when it silences its gate — and opening it flips the mode silently (ensureModeAdmits) with a one-line, self-clearing confirmation strip as the only announcement. Ranking is deliberately NOT mode-aware: results keep one stable destination order in both modes, because an exact match the user asked for must not sink for living in a Power-User area, and a list that reorders on a mode flip reads as random.

What breaks if this is ignored: index instances and a twelve-space config turns “gap” into sixty rows; enrich on the match path and every keystroke pays a disk or AX round-trip the moment someone adds “just one” richer column; rank by mode and the same query answers differently before and after one click.

A search hit lands on the control only where the section hides it (#277)

Section titled “A search hit lands on the control only where the section hides it (#277)”

[Rationale]

A result navigates to a destination and then reveals a target inside it, and the catalog is what makes a target a CONTROL rather than the section around it. It could carry every census row; it carries the rows a section landing would leave unseen, and the line is drawn where the harm is:

  • A row the census places behind a disclosure (.showMore) takes an anchor, because without one the hit lands on the destination root with the drawer shut — a page showing nothing the result named. That is the one case where the section landing fails outright.
  • A row visible at rest (.atRest) takes none. The section landing already shows it; an anchor would add a wash and, as the reveal is shaped, scroll the row to the top edge with its heading off (docs/ui-patterns.md ▸ a revealed target). A wash on a control already in view is coverage for its own sake, and it costs the heading. (The App Bar’s two Show-it-in toggles predate the ruling and stay as shipped.)
  • Advanced Colours’ rows take none whatever their tier. Colour selection is a browse, not a name search: nobody types “group badge text colour”, they open the page and look.

The split is keyed on the census TIER, so a row moves across it by correcting the census, never by an anchor added beside it — and a tier the census states wrongly (a .showMore row a card draws at rest) carries an anchor until the census is corrected, which is where the drift shows rather than in the reveal.

Two rows stay outside the split for a mechanism reason rather than a product one, and are recorded there rather than here: a census row whose label key another census row shares cannot be told apart by a join that reads the key alone, and a context-menu item has no rendered row to anchor (.claude/rules/gui.md).

A hit inside a filled drawer opens the drawer and washes the row; SettingsSearchDrawerAnchorTests holds every filled drawer and SettingsCatalogDrawerTests the ones ruled childless.

The search panel shows nothing before you type

Section titled “The search panel shows nothing before you type”

[Principle] (#1030 tried the opposite; #1470 retired it, owner ruling 2026-09-15)

A focused search field with nothing typed shows no panel. The field is the window’s first text field, so macOS hands it the arrival focus on every open — and a panel with rows before a query therefore dropped over Home every time Settings appeared (#1468). #1030 had put one row there, the Guide, on the argument that an .accessory app has no Help menu and the search field is the closest thing to one; that argument now belongs to the Mac Checklist’s foot, the app’s one permanent guide pointer (▸ The app links the guide…). What remains from that pass is the rule it also stated: results("") returns nothing, so a bare Return in an untouched field cannot navigate somewhere the user never named (SettingsSearchTestsemptyQuery), and any list volunteered under the field would be a second navigator, which “Home is the only navigator” rules against. The narrower fix — keep the row, show it only on a focus the USER gave — was refused too: telling a click or ⌘K from the window’s arrival focus means reading focus provenance at the field, which is the #991 trap (currentEvent is the last event retrieved, and a VoiceOver press has none), and SettingsInputSourceSeamTests bounds who may read the input source at all.

Two settled behaviours from that pass stay: Escape on an EMPTY field resigns focus (clearing an already-empty query would leave the field uncloseable from the keyboard), and the narrow-width entry collapses once neither focus nor the pointer holds it, not on blur alone. Where focus goes after that Escape is ruled, not stated: nowhere. The window has no focused control, and the next Tab restarts the key loop at its first key view — the search field, which the guide already makes the window’s focus home. Escape means “stop searching”, Tab means “start from the top”; sending focus into the page instead would move it somewhere the user was not looking (owner eyeball 2026-09-14, keyboard navigation on, wide and narrow). HomeSurfacingTests needles the query-only panel and the collapse.

Hover help appears sooner than AppKit’s default

Section titled “Hover help appears sooner than AppKit’s default”

[Trade-off]

KiwiDesk registers a shorter NSInitialToolTipDelay (700 ms) than AppKit’s default of roughly two seconds. Registered as a fallback, never set: a user who has chosen their own delay keeps it.

This is a deliberate deviation from “standard controls work the standard way”, taken because the default makes a specific explanation channel unreadable. A greyed control’s “why can’t I touch this” sentence is a hover string; at two seconds, a user who moves the pointer onto a dimmed stepper and pauses to read sees nothing, concludes there is nothing to see, and leaves. The sentence was written, translated into eleven locales, and delivered to nobody. Shortening the delay is what makes the fallback a fallback rather than dead copy.

It does not promote hover to the primary affordance. A block gate still explains itself through a live ? outside the gated subtree, and a control-scoped gate still leans first on the gating control sitting directly above it (#527) — hover remains the last of the three, and a surface that needs hover to be understood is mis-designed.

The floor matters as much as the ceiling: below roughly half a second, tooltips fire while the pointer merely crosses a row on its way elsewhere, and Settings becomes a field of popping yellow. Late help is better than that. 700 ms is chosen to be clearly faster than a pause-and-give-up while still requiring the pointer to actually rest.

Permanent accessory mode (no activation policy switching)

Section titled “Permanent accessory mode (no activation policy switching)”

[Principle]

No window controller may change the activation policy. A content window comes forward through NSApp.forceFront, which shows and activates it from .accessory; opening Settings adds no Dock tile and no menu bar. This removes the macOS 14+ demotion bugs and focus-handoff lockouts wholesale, so shortcuts and focus commands stay reliable without any dynamic switching.

Stated as an obligation on controllers rather than as a claim about the process, because the claim was the failure. The rule used to be promote-on-open / demote-on-close, and a demote had to survive being the last of {Settings, onboarding, Config Issues} to close — one rule spread over three controllers, each holding half of it. Removing the demote from Settings while leaving the promotion in onboarding left exactly one reachable order (onboarding → Settings → close both) that stranded the app .regular with nothing on screen, which is the invisible-but- foreground state that breaks focusedCommandDenial’s front == focused.pid test. Not promoting is the only form of the rule with nowhere left to forget it.

One exception, and it is structural rather than trusted: the already-running alert in SingleInstanceGuard raises .regular so its modal is not buried, and the process exit(1)s immediately. No window can outlive that promotion, so it cannot strand anything.

The menu bar this policy hides is still built (MainMenu) — AppKit routes key equivalents through NSApp.mainMenu whatever the policy, and it is what gives the Settings text fields Cut/Copy/Paste/Undo.

Corollary: nothing arrives in front for free, and that reaches windows KiwiDesk did not open. A .regular app has a Dock tile and everything macOS builds on it — the icon bouncing for requestUserAttention, the user clicking it to come back. An .accessory process has none of that, so a window that finishes something already begun activates at the moment it appears — and so does one the user’s own click just cost them, because an accessory process with no windows left is deactivated by macOS and the next window it opens lands behind whatever took over. KiwiDesk’s own take NSApp.forceFront; a window a framework opens takes the seam that names its moment, and where the framework offers none, that seam is worth building rather than approximating with a nearby hook — the neighbouring callback fires while the framework is still preparing the window, which is a race dressed as a fix. Coming forward is not enough by itself either: an affordance that lets the user park such a window out of reach is refused, because activating a process deminiaturizes nothing.

The scope is deliberate and the other half is the opposite rule. An unsolicited offer must NOT take the screen — that is the same argument Background update checks are on, and there is no switch makes about a modal at the worst moment, and it is why this entry never brings a SCHEDULED alert forward. The obligation is on the interaction the user is already inside, never on the one being proposed to them. The scheduled alert is therefore never shown by Sparkle at all: the offer is a mark on the menu-bar item and a row in its menu (Scheduled update reminders are a mark, not a notification), and acting on that row is the user beginning the interaction, so the alert then comes forward under this entry’s rule.

Sparkle is the worked case (#1011). It activates for the windows it opens on a check the user asked for, then marks the later install-and-restart prompt with requestUserAttention alone — right for a Dock app, inert here. The prompt arrived behind everything the user had open with nothing saying the update was waiting, which makes the in-app update path read as broken: the exact trust the section above exists to keep. UpdatePromptDriver overrides showReadyToInstallAndRelaunch — the one moment neither Sparkle delegate protocol reaches — while UpdatePromptFocusTests holds the activation inside that override and UpdatePromptWiringTests that Sparkle is shown through that driver rather than a stock one, which is the way an override goes dead without anything noticing.

Settings is miniaturizable; modal chrome is not

Section titled “Settings is miniaturizable; modal chrome is not”

[Rationale]

Settings carries .miniaturizable in its style mask, while ephemeral/modal chrome (onboarding, Config Issues) does not. Settings persists and tiles alongside user workspaces, so the standard macOS minimize affordance (the yellow traffic light and ⌘M) works as expected, parking the window in the Dock’s recent-windows section or allowing quick restoration from the menu bar or hotkey. Ephemeral completion surfaces (the tour, Config Issues) stay un-miniaturizable so they are completed or dismissed rather than parked indefinitely.

The tour is chrome, and chrome is not tiled

Section titled “The tour is chrome, and chrome is not tiled”

[Principle]

Tiling manages the windows a user keeps; a surface with a completion condition is chrome, and chrome is outside the tiler’s domain. The tour, the ⌃⌥K panel and the Config Issues window all end, so none of them is managed — and none of them is a “floating exception”, which is the framing every future surface would otherwise claim. Settings persists and is resized beside your work, so it tiles.

The question this answers is a fair one: a tiling manager whose own first window floats looks like it is exempting itself, and a new user has no way to read that as anything but inconsistency. But making the tour a managed window answers it wrongly. At the grant step there is no permission yet, so nothing tiles whatever the policy says; by the time tiling begins the space holds the user’s browser, their terminal and the System Settings window they just granted from, so the tour would become one slot among them at whatever width the layout hands it, with copy authored for its own fitting size. It would also have to give up the raised level it takes at exactly that moment (BarPanel.aboveLevel), which is the only thing stopping the retile burying it. And the lesson it would teach is not “KiwiDesk tiles” — it is “KiwiDesk grabbed and resized the window I was reading”, which is the fear a new tiling user arrives with.

The demonstration was already happening, unnarrated. The moment the grant lands, management starts and every window behind the tour is arranged — over the following seconds, since #801, and the screen now narrates that wait rather than claiming a finished job (▸ Boot: the wait is narrated, never hidden). That is a better demonstration than tiling the tour could ever be — the user’s own windows, at the moment it means something, at no cost — and the tour used to answer it with “Permission granted!”. It now points at it, and says once that setup windows are left alone because they go away. Said once, the exception stops reading as an inconsistency and starts reading as a rule. (#678 Phase 4 pass 11)

Settings tiles — discriminated per window, never per process

Section titled “Settings tiles — discriminated per window, never per process”

[Principle]

The Settings window tiles like any other window, and the force-float policy asks which own WINDOW it is looking at, never merely whose process it belongs to. (#678 item 18, Phase 5.) The engine used to force-float every own window by isOwnProcess(pid) — one predicate answering for three titled windows that want two different fates, Settings against the tour and the Config Issues window. (The app’s utility panels never reach it: shouldIgnoreOwnWindow drops non-key overlays and borderless panels before tracking.) Relaxing that predicate per PROCESS would have swept the tour and the Config Issues window into layouts alongside Settings, so the exemption rides a per-window mark instead (OwnWindowTiling.identifier, stamped by SettingsWindowController alone — OwnWindowTilingSeamTests’ map is the one copy of who may). The entry above rules WHICH windows are chrome; this one rules the mechanism: an own window is chrome by DEFAULT, and tiling is the marked exception — a new own window floats until someone argues otherwise, which is the failure direction that costs a misplaced float rather than a stolen layout slot.

Two consequences worth stating because each was reachable another way. The app stays .accessory throughout — tiling a window and promoting a process are unrelated axes, and a window that takes a layout slot is not thereby a reason to grow a Dock icon; the activation-policy seam is untouched. And the ⌃⌥K panel needs no mark and no bar exclusion: item 18 asks for one, but an own borderless NSPanel is ignored by shouldIgnoreOwnWindow before tracking and it reaches no bar’s enumeration to be excluded from. A written exclusion would have been dead code asserting a fact the type system already holds.

The companion affordance: “Open Settings” is bindable and unbound by default (KiwiDesk.open_settings(), offered under Shortcuts ▸ General). Settings is not a prerequisite — the app works untouched out of the box — so no default chord is spent on it; but a window that now lives among the user’s tiled windows earns a keyboard road back. It opens or raises, never toggles: a close bound to the same key would discard the draft the save pill narrates. The key spelling follows the verb rather than the label — open_settings, matching keybinding.open_settings, while the row reads “Open Settings” — and it is a different word from its sibling show_shortcuts on purpose, one toggling a panel and the other opening a window.

Where the app lives is taught inside the tour’s own window

Section titled “Where the app lives is taught inside the tour’s own window”

[Principle]

The tour says where KiwiDesk lives with a picture on a surface KiwiDesk owns — never with an overlay drawn on the desktop beside the real menu-bar item. The closing card carries a small drawing of a menu bar with the app’s own mark in it, and the sentence under it says what clicking that mark opens.

Pointing at the real item is the obvious answer, and it was built that way once: the window vanishing is exactly the moment someone wonders where the app went, so point at the thing itself. What that costs is a promise the app cannot keep. Outside its own windows KiwiDesk is drawing against a strip it does not control — a menu bar set to auto-hide is not on screen at all (common among the keyboard-driven users this app attracts, and the same defect that retired #331’s timed popover), and a menu-bar manager may have parked the item off the visible strip or somewhere else entirely. An honest overlay therefore has to skip itself, and it skips for precisely the user who most needs telling; a dishonest one points at empty screen.

So the ruling is about the surface, not the artwork: what the tour promises, it promises inside a window it drew. Two things follow for anyone redrawing this card. The picture uses the real menu-bar image rather than a stand-in symbol, because what the user has to recognise is that artwork among other icons — a symbol that merely resembles it teaches the wrong shape. And the picture is a picture: it is not a control, it points at nothing off-window, and it needs no permission, no screen geometry and no guess about where the item ended up.

The tour’s progress row is derived, never a fixed counter

Section titled “The tour’s progress row is derived, never a fixed counter”

[Principle]

No screen of the tour asserts a total it cannot know. “Step 2 of 4” is false at any door that opens past the first screen, and the reader it lies to is the one least able to tell a skipped screen from a broken one. The tour’s length genuinely varies: a tour reopened from Settings starts past the screens that have nothing left to say, and any future machine-gated step widens the variance again (the Displays recommendation was that step until #888 retired it — the principle predates its retirement and does not lean on it).

The row of pips at the top of each screen is not that counter re-admitted. The banned thing is a fixed total; a plan derived from the screens this presentation will actually show is a different claim, and it is true on every path. What makes it true is that the plan is the itinerary rather than a description of one — the flow walks the same list the row draws (OnboardingModel.plannedSteps), resolved once when the window opens, so the two cannot answer differently and nothing the user does mid-tour re-numbers the pips they are reading.

The obligation on a future author is therefore narrow and absolute: a progress indicator here is drawn from that plan or it is not drawn. One sourced from every step the flow can have, from a constant, or from a count that ignores which screen the tour opened on is the banned counter wearing a new name, and the repair is to delete it rather than patch it.

The tour teaches the tier, and names the keys it teaches

Section titled “The tour teaches the tier, and names the keys it teaches”

[Principle]

A chord the reader cannot press is not taught. The tour’s keys step draws the shortcuts KiwiDesk seeds, and it shipped drawing them as native glyphs alone — ⌃⌥ ← ↓ ↑ →, ⌃⌥ 1–5. That is a perfectly good reminder for someone who already knows the symbols, and it is illegible to the reader the screen exists for: ⌃ ⌥ ⇧ are exactly the three glyphs a person who has never pressed a Mac keyboard shortcut cannot name, and on a German keyboard the caps print “ctrl” and “alt” rather than the symbols at all.

Two obligations follow, and they are one screen’s worth of work.

Each modifier is its own key, drawn as one. The chord is a row of chips with + between them, not one chip with a glyph run inside it: separating them is what makes ⌃⌥⇧ read as three keys to press rather than one symbol to recognise. The + sits BETWEEN chips and never inside one, which is what keeps ComboSymbols’ rule intact — that library drops the separator precisely so a + appearing inside a chord is the KEY (⌃⌥+ on a German layout), and a chip boundary separates two keys where a loose + would be ambiguous.

The word under a glyph is an abbreviation, and it is not localized. ctrl, opt, shift, cmd — read the same way in every language KiwiDesk ships. They are language-neutral tokens like the glyphs above them, so they carry no catalog keys: four fewer strings to mistranslate and one fewer line on every locale round, for nothing given up.

Two rejected answers are worth recording, because both look right until they are checked. Writing what the CAP prints, per locale — German “alt” — uses the UI language as a proxy for the physical keyboard, which is wrong for anyone running German on a US layout; and it coins a second name for a key the app already names one screen away, in key_recorder.help_press, which is the Shortcuts editor’s own help and where the reader goes to change these very chords. Using that screen’s full words (Control / Option / Command) is right about the vocabulary, and loses on MARGIN rather than on fit: measured against the 560 pt window, the widest seeded row with full names fits in every locale — but German fits by about two points, one longer label or one wider translation from wrapping, where the abbreviations leave it tens. OnboardingModifierNameTests holds the numbers; they are deliberately not repeated here, all three copies of them having been wrong at once (one had measured Italian’s modifier names against a German label, a pairing that cannot occur).

An abbreviation of the app’s own word is not a second word; the reader who wants the full name meets it in the editor. That is exactly true in the seven catalogs that keep the English modifier names, and it is a known split in the three that do not — es, it and pt-BR render key_recorder.help_press as “Opción”, “Controllo”, “Comando”, so those readers meet opt in the tour and a translated word in the editor. Accepted rather than overlooked: the alternative is either a per-locale abbreviation, which is the cap-printing draft rejected above, or localizing four tokens whose whole value is that they do not vary.

Every glyph carries a word, including the ones “everybody knows”. ⇧ shipped bare for exactly one build, on the reasonable argument that it is on every keyboard ever made and needs no gloss. It looked broken — a gap under one chip in a row of four, which a reader takes for a rendering fault rather than a judgement about which symbols are obvious. The gloss is also free: measured, every abbreviation is narrower than the 25.4 pt chip above it, so the columns are chip-bound and no word here costs a pixel. There is nothing to buy by withholding one.

And the rule outranks the rows. The seeded keymap is a tier system — ⌃⌥ moves the focus, ⌃⌥⇧ moves the window, ⌃⌥⌘ moves it and follows, and ⌥⌘ sizes it — so a list of unrelated rows asks the reader to memorise every chord separately when there is one scheme to learn. The step states the scheme. But it states it derived from the live chords, never asserted: every glyph on that screen is looked up rather than written, and a sentence claiming a tier is a claim about two modifier sets, so a user who rebound the second tier is told nothing at all. Silence is the correct failure here — the screen without the sentence is exactly the screen that shipped before it, while the sentence with a rebound keymap behind it teaches someone else’s keyboard.

The Mac Checklist counts what macOS can confirm

Section titled “The Mac Checklist counts what macOS can confirm”

[Principle]

A checklist row is a change made ONCE that can be found DONE — by macOS, read live, or only where macOS would not answer, by the user’s own tick. A habit is kept, never found done, so it is prose and joins no count. The card (#1365, owner re-ruling 2026-09-13, ui-designer 2026-09-14) exists because a tiling window manager works best when a few macOS settings change with it, and a list with a number gets walked where a guide section gets skimmed. The number is the point, and it is only worth walking if it is true.

Three rulings follow, and each has a shape someone will want to undo:

  • The count is the essentials, and nothing else. Two settings are optional (the wallpaper click, the double-click zoom — a preference the app can already absorb), and they draw their own “Set” / “Not yet” chip but move no number: folded in, “3 of 6” reads as half-broken on a Mac where every essential is set, and a second count puts two numbers on one card face. Nor does a habit take a “got it” checkbox that counts — a pledge is not a fact, and a count built partly on pledges is a card that is not an answer (gui.md ▸ Home). MacChecklistProgress is the one home of the count; the card face and the section header both read it. The card face draws the essentials as the rows’ own ticks — the filled check and the hollow ring, same inks — never a chart of the number: a row of squares shipped for an evening and read as a bar graph, and a bare face sat shorter than every other Whole App card (owner, 2026-09-14). The subtitle carries the spoken count; the ticks are hidden from VoiceOver like every other card preview.
  • Detected rows are chips, not controls; the self-tick is the FALLBACK. macOS answers these rows, so the tick is not the user’s to toggle, and greying a checkbox would say “switch something on and I act” (gui.md ▸ grey, don’t hide) about a thing nothing in KiwiDesk switches. Where a read comes back in a shape this build does not know, the row turns into a native checkbox with a caption that says so — never a false “Not yet”, since a detected tick that lies is worse than a self-tick, and a count that can never clear never motivates (#1094). Absence is the shipped default and is judged as such; MacSetting carries the absent values as read on macOS 26.6, and a macOS that moves one moves that table.
  • The rows are read at the shell, not at the card. One snapshot on the model, taken when Settings appears and again when the window comes forward, so the Home card and the section count the same read and a user who flips a switch in System Settings and comes back sees the row update — the LoginItemCard shape. KiwiDesk writes none of these settings — they are the user’s, and a tiler that flips them is the thing this list warns about (#1365, alternatives considered): the caption’s link opens the pane, and the breadcrumb carries the rest, because sub-pane anchors are undocumented and move between releases.

What is deliberately NOT on the card: Displays have separate Spaces (both states are supported and the app does not ask you to change it — the Desktops row is descriptive, never prescriptive), a KiwiDesk to-do (“find your palette, add a Space”) whose items are mostly undetectable and would turn the count into a tour score, and how-to tricks, which are the guide’s. And the tour’s last button now lands on this card: a user who pressed “Start using it” used to land on nothing, and the card reaches a new user only if the tour hands them to it — so the first thing they see is “Essentials done: 0 of 4”, which is why the count is essentials only and the card never shouts. This does not reopen the #678 Phase 4 pass 11 ruling that moved the tour’s default action off Open Settings: that ruling was about handing a new user the whole Settings window to dig through, and this button lands them on one card of things to finish, the same shape as the tour’s own steps. It is also why the card sits LAST on Home, after General: the tour carries discovery, Home carries return visits, and a checklist most people finish once is the right card to orphan where the Whole App row wraps. A habit that names a macOS switch — the Dock — links the pane the way the settings rows do, and the keyboard habit names the shortcuts panel’s LIVE chord, read from the resolved layer like the tour’s keys, with a second frame for a Mac where nothing is bound.

Section titled “The app links the guide, in a language the site actually serves”

[Rationale]

For a long time nothing in KiwiDesk named the written guide at all. A user who finished the tour and later wanted to make the setup theirs had to find the site on their own.

What the tour’s closing card gave up when #1019 put the pointer there is the more interesting half. That card used to end on a quiet paragraph — “Settings is where you change any of this — different keys, more Spaces, other colours. If this is your first tiling manager, you do not need it today” — above a footer offering “Tiled before? Open Settings”. Both are gone, and the card is better for it.

The first clause was the menu-bar card directly above it saying the same thing a second time, and that card says it beside a PICTURE, which is the version that teaches. The second clause sorted the reader before it reassured them: it made beginner-against-experienced the organizing idea of the last thing the tour says, and it carries a false converse — a reader who is NOT a beginner is told by implication that they DO need Settings today. The footer then asked the same question again in the other direction. Nothing on that screen needs to know which reader it has.

A closing screen ends with one action and one destination. Before this the card said four things about where to go next, and two of them argued with each other in the reader’s field of view: Settings is where you change everything, you do not need Settings, open Settings, start using it. Removing the fork beats re-wording it. What remained was the button and the pointer.

Dropping the tour’s Open Settings link EXTENDS the #678 Phase 4 pass 11 ruling rather than contradicting it. That pass moved the default action off Open Settings because this app’s position is that Settings is for people who want to dig deeper; a bottom line still offering Settings was that same ruling being argued with in a quieter voice. Nobody is stranded: the picture above names Settings and where it lives — the durable route, the icon they will still be using on day 30, against a one-time button in a window that never returns — the tour reopens FROM Settings, and KiwiDesk.open_settings() is bindable.

Since #1365 the default action lands on the Mac Checklist card; ▸ The Mac Checklist counts what macOS can confirm argues why that is not the Open Settings link coming back.

The banner is one-shot, so only a permanent pointer closes the gap. Home’s first-run banner reaches someone who closed the tour early, or finished it months ago and is in Settings for the first time — and it retires permanently on dismiss or on the first save, so a user who dismissed the welcome, or simply saved one change, had no route to the guide anywhere in the app. That is the gap this entry is about, not a nicety on top of it, and only a permanent pointer closes it.

That permanent pointer is the Mac Checklist’s foot (#1365, #1470). It was General ▸ About’s bare “Guide” link, and the tour’s closing card carried the sentence too; both went with #1470 (owner ruling 2026-09-15): the tour’s button now lands ON the checklist, one line above its own copy of the sentence, and two permanent pointers to one page is the drift a one-pointer entry exists to stop. The search declaration moved with it, so “guide” typed into the field still lands on the pointer (GuideLinkSurfaceTests is the register of the two sites, the one URL reader, and the mount count). The pointer is permanent only where the checklist is OFFERED — in Simple, on every display count, withheld only while a stored profile is edited, exactly as About was (HomeCardOrderTestsmodeCounts, DestinationParityTestsprofileContextExcludesOnlyProfileless) — so a change that withholds the checklist further owes the pointer a new home in the same change.

The two surfaces share ONE frame and one label, because the same English names the same action at both; the search row draws the bare destination name, “Guide”, since a sentence is not a row.

It links /guide/, not /docs/user-guide/. They are different documents for different readers: /guide/ is the single-page newcomer guide, the docs tree is the canonical reference for someone who already knows what a tiling manager is. The sentence is read by the former.

The language follows the app only where the route exists. KiwiDesk ships eleven catalogs; the site has three locales. A link composed from the app’s language would send the other eight to a 404 — so the app narrows its own locale to the routes the site serves and falls back to English otherwise, because a live English page beats a missing page in the reader’s own language. That is the same rule the site’s own sitemap already keeps for the same reason: a path is only treated as localized once its /de/ and /ja/ routes genuinely exist.

The guard is deliberately one-directional, and it lives on the site’s gate rather than in a Swift suite. site/** is on .github/ci-ignore.txt, so a change confined to the site skips the app’s jobs — and a site restructure dropping /de/guide/ is precisely the change that would otherwise land with the app still linking it. So the check that every linked route is served runs in scripts/check-site-tokens.py, on the site build, over the built pages; the workflow takes SupportLinks.swift as an input so the check runs when either side moves. A locale the site GAINS does not fail: the app keeps sending that reader to a live English page until someone widens the list, which is the safe direction to be stale in.

Nothing preflights the network. The URL goes to the browser and the browser reports its own failure. An app-side reachability check cannot tell a down site from a captive portal from a machine that is simply offline, and a wrong “you are offline” beside a working link is worse than the browser’s own error page.

[Principle]

Settings owns the login item; crash supervision is the CLI’s. (#342, #576, #678 item 16, re-ruled by #1071) General shows ONE switch — “Start at login”, in the “Applies immediately” group — and it drives the SMAppService login item and nothing else. The kiwidesk service LaunchAgent, which adds KeepAlive crash restart, is reachable only from the command line.

Why the second switch went away. It was never one setting with two faces: the two mechanisms are two launchers, and having one switch install both meant they raced for the instance lock at every login. That race is not cosmetic — whichever launch loses decides whether supervision runs at all, and the losing launch is what produced #1068’s ten-second focus theft and #1071’s silently idle job. The GUI curates and the power layer is open (gui.md’s north star): a risky-but-valid knob is hidden from the GUI and left to the CLI rather than guarded with a second switch that cannot express the state honestly. Two doc corrections failed to describe the old behaviour truthfully before this was ruled, which is the evidence that it could not be described — a coin flip the user cannot see has no honest short sentence.

What the mainstream user loses is a crash they can answer themselves. KiwiDesk’s crash is not silent in practice: the menu bar item disappears and the shortcuts stop, and reopening from Spotlight takes a second. Set against a launcher race and a KeepAlive loop with no breaker on a deterministic crash, the supervisor is not what a non-technical user needs from the window manager — it is what someone running it as infrastructure needs, and they have a terminal.

A state only the CLI can reach may name the CLI. While the service is loaded the login switch reads ON — true, KiwiDesk does start at login — and goes inert with its reason inline, naming kiwidesk service stop. That is the one place Settings prints a shell command, and it is sound because the gate decides the audience: the caption cannot render for anyone who did not run the CLI to get there. The same reasoning as config presence expands the Simple surface — show what someone has, withhold only the offer.

Onboarding’s closing card keeps its own pre-checked box, “Start KiwiDesk at login” — the login item, the same one thing. Rulings a contributor might otherwise undo:

  • Default At Login, auto-restart opt-in. Most apps default login-launch to opt-in because “not running yet” is a neutral absence. A tiling WM has no such neutral: after a reboot, not launched means every window on the machine is unmanaged until the user remembers to open a menu-bar app with no Dock icon prompting them. The off-state is a broken desktop, so the good default is At Login — which is why “approachable by default” argues for pre-checked here. Supervision, though, lacks that no-neutral-absence argument and installs a less-discoverable LaunchAgent, so it is never on by default and, since #1071, never offered in Settings at all — the good default is the login item alone.
  • No modal on every start. A dialog that asks “open at login?” each launch was considered and rejected — it is the same standing-nag shape the quick-menu Accessibility deep-link was cut for, only worse (a modal blocks; a menu row doesn’t). Once answered, re-asking is either a persistence bug or a nag; there is no informative third case. Ask once, then the durable control owns the decision.

The impossible pair was refused in the fold, not made unrepresentable — which is why splitting the control was survivable, and why removing it costs nothing. The service is RunAtLoad + KeepAlive as one indivisible unit, so “restart on crash” is a superset of “open at login,” and two independent toggles can render Open at Login: OFF + Restart: ON — a state whose first control’s own label is false while the app still launches at login. #576 answered that by folding both into ONE three-level picker, which made the pair unrepresentable. Turn 14b (#678 item 16) splits the picker back into two switches, because the supervision half is advanced and does not belong beside the language pick — which re-opens the shape #576 closed. So the constraint had to move rather than disappear, and it moved down a layer to where it is total: AutoStartLevel.level(openAtLogin: restartOnCrash:) discards the restart flag whenever login is off, so no caller could express the contradiction whatever its toggles said. That is the layer that holds for a CLI verb, a restored preference or a test — anything that never passes through the view.

#1071 ended the pair by removing the second switch. The GUI expresses no level at all now: it writes the login item through SettingsModel.setLoginItem, and the level ladder survives as the READ that folds both mechanisms into one answer. Nothing writes a level any more, which is why setAutoStart and AutoStartManager.set/apply went with the row — a write path for a pair nobody can express is dead weight that would invite the pair back. Making it unrepresentable in one control was #576’s answer; refusing it in the fold is what survived the control being split; not having two controls is what ended it.

The AutoStartManager facade owns that coupling (the GUI analog of CLIMain.runService): ServiceManager stays a pure launchctl path and never imports SMAppService, and the facade folds the two into an AutoStartLevel. Because launchctl is a blocking spawn, current()/set() are async off the main actor and each switch shows a transient pending state — a blocking Process in a SwiftUI body would be the AGENTS.md violation the CLI-only fallback existed to avoid.

The switch reads through, so it reports what the OS holds — including ON while the service is what starts KiwiDesk. With one control there is no pair to remember and no third state to lose: the switch answers “does KiwiDesk start at login”, which is true whichever mechanism does it, and goes inert while the service owns the answer. Storing a preference instead would mean holding a value the OS itself does not have, which is the exact drift read-through exists to prevent.

The control is read-through, and the two subsystems are the authority. It never caches a bool — every level is derived from a fresh dual read (SMAppService.mainApp.status + ServiceManager’s structured launchd state) on appear and on didBecomeActive, and a set(_:) re-reads, so a change made in System Settings ▸ Login Items directly is reflected without a second source of truth. A .requiresApproval status reads as the At-Login level (the user’s intent) with a jump to Login Items, reusing onboarding’s “asked, not yet confirmed” shape. .notFound is the pre-registration state macOS reports for mainApp, so it reads as off-but-registerable, not as an error. A copy that genuinely cannot register greys the switch out (grey, don’t hide) — the login item needs a stable .app path, so there is no valid “on” and only off remains, matching the #171 “inapplicable control is greyed, not hidden” precedent. The control is greyed rather than its row, so its ? help stays readable, and the reason-specific caption (a live sibling) names the fix for the specific cause: move to Applications for a Gatekeeper-translocated download, run the packaged app for a bare non-bundled binary (the device-QA .build/release path). The registerability check is a location fact, evaluated before the OS status, so it holds even if a prior install left a stale registration. The service’s KeepAlive { SuccessfulExit = false } restarts only a crash — a deliberate Quit is never resurrected. The overlap that used to be invisible (a loaded service’s RunAtLoad also launches at login) is now something a user assembles deliberately from two surfaces rather than one switch installing both, made runtime-safe by the #196 instance lock — and by that second launch exiting successfully, since KeepAlive { SuccessfulExit = false } would otherwise read the decline as a crash and respawn it every throttle (#1068; SecondLaunchExitTests pins the exit status and the plist clause together). The lock alone was not enough: it deduped the processes and said nothing about what the loser reported.

[Trade-off]

The appearance pick is stored in app preferences and applied to NSApp, never in gui.json and never through SwiftUI’s preferredColorScheme. (#678 item 8) General offers System / Light / Dark; System is the default and stores nothing.

  • Storage is UserDefaults, not gui.json — the same reasoning as the GUI language pick. It is an app-wide display choice, not part of any profile, and writing it must never create a config sidecar: that would flip KiwiCore.isGuiManaged and hand config ownership to the structured loader for a user who never adopted the GUI (profiles.md: a profile may not override a setting that lives outside config ownership). .system removes the key entirely, so “follow macOS” leaves no trace, and an unknown stored value — a hand-edited domain, a case removed later — reads as .system rather than trapping, because this runs at window construction and refusing to open Settings over a bad preference string is the worse failure.
  • Applied to NSApp.appearance, not .preferredColorScheme. The SwiftUI modifier sets only the hosting window, which fails two ways. It is too narrow — item 8 asks that every surface have a dark counterpart, and the bars and border overlays are their own windows a Settings-view modifier never reaches. And it does not cleanly revert: AppKit-backed subviews (NSViewRepresentable captions, and at the time the sidebar’s visual-effect backing) resolve their appearance when made and do not re-read it when the modifier returns to nil, so Dark → System stranded them dark while Dark → Light — a new concrete value — looked fine (found on device before it shipped). Assigning NSApp.appearance = nil hands the decision back to macOS, and AppKit propagates it to every window, including ones opened later. One surface overrides the pick by ruling: a bar’s Liquid Glass with a dark Fill pins .darkAqua on the glass view whatever the pick says — the Fill decides where the bar is dark, the pick the rest (#1308, the Liquid Glass ruling under App Bar).
  • Core carries the choice, the GUI maps it (the #96 seam applied to a value type). AppearanceChoice is a case with no AppKit in it; the mapping onto NSAppearance lives where AppKit does.

[Principle]

A layout is chosen by its picture, not by its name. Layout Defaults selects one of the tunable layouts — every layout but Floating, which has nothing to tune — and “Track” and “Scrolling” and “Monocle” are words only somebody who already uses a tiling window manager knows — which makes a strip of those words the worst possible label on the one page where a beginner is most lost. So the selector draws each layout instead, and the tile doubles as the answer to the question the reader actually has, which is not “which of these is called Track” but “which of these do I want”. Each tile also carries the count of spaces using it, so a layout nothing runs is visibly not worth tuning — the page edits defaults, and a default with nothing reading it is an hour spent on nothing. The obligation this creates: the tiles are the same schematics the page’s own preview draws, from the same staged settings — a strip of stylised mock drawings would be a second, quietly disagreeing picture of the same configuration.

A preview that takes a window count simulates; one that does not illustrates. Several Layout Defaults settings are invisible at any fixed number of windows. Cascade overflow and Cascade all draw the identical frame until the stack is deep enough to overflow; a track limit means nothing until there are more windows than tracks; a dynamic grid’s balance only shows as it rebalances. A still frame at a baked-in count therefore cannot teach the setting it is under, and the reader has to save, watch real windows, and come back. So the count is an input on a slider, and each schematic runs its real fill logic against it. The obligation that carries: call the engine wherever the engine can answer, so that much of the picture cannot drift — BSP tiles through BspLayout and a dynamic grid takes its balance from GridLayout.balanced rather than a copy of the arithmetic. The rest is hand-drawn for two different reasons, and they want different remedies: some of the engine’s constants do not suit a mini canvas at all (the cascade’s 40 pt title-bar reveal would throw tiles off it), while some rules — a grid’s fill order, its capacity clamp, the leftover span — have no such obstacle and are simply not exported apart from calculateGeometry. The second kind is the standing reason to widen the seam rather than the licence not to: a schematic reproducing a rule the engine already owns is a picture that stops agreeing with the layout the day the rule moves. Two consequences worth stating, because both look like regressions in isolation: the two-frame “4 windows → a 5th opens” growth pair retired, since a reader who can add the fifth window themselves does not need it staged; and the count is view state that resets on leaving, because it is a question asked of the preview rather than a setting — writing it to the config would be a second, invisible knob. What the count does not buy is a render of the user’s actual windows: that needs live window state, which is exactly the live-apply coupling #123 rejects (see accepted limitations).

Where the engine’s rule needs a display, the preview stands a number in for the display — never a simpler rule. (#708, extending the paragraph above; the pattern was first ruled for Grid in #712.) Some engine rules are not merely unexported, they are unanswerable on a mini-canvas: how many minimum-size cells a grid fits, how many windows fit in one track before a new one opens, how many tracks fit across a screen. Each is a function of min_window_size against real geometry, and the canvas has neither.

The tempting escapes are both worse than the problem. Dropping the rule ships a preview that teaches a behaviour the app does not have — the Track schematic did exactly this, growing the focused track to a drawn ceiling and piling the surplus, while the app has filled-then-spilled into a new track since #437. Disclaiming it in the caption (“this preview shows position only”) leaves the reader knowing a rule exists without knowing what it is, and a caption’s job is to label what is shown.

So: keep the engine’s rule, substitute the display quantity, as a named constant that says it is a stand-in and argues its value. The rule then still comes from the engine — Track asks TrackLayout.spillsToNewTrack, the same predicate a real spawn asks — and only the number is local. Two obligations make the substitution honest rather than a clamp by another name. The stand-in must be the same at every drawing scale, or one configuration draws two different capacities and the thumbnail contradicts the panel (#712’s first cut did exactly that, and review caught it before it shipped: a rigid 8 × 1 at five windows piled two windows on the strip thumbnail and none in the panel, inventing an overflow the engine does not have). And it must not bind below a value the user typed — a preview answering a typed limit of 4 with three tracks is a stand-in overruling the setting it illustrates. Clamp the drawing if you must; never the rule. The family lives in LayoutSchematicStandIns and LayoutSchematicTrackFoldTests holds the scale independence.

A layout gets one frame, whatever it has to teach (#753, superseding the #125/#239 two-frame bar, which stood in UI patterns ▸ Previews & schematics). That earlier rule let a layout earn a second frame — two mini-screens with an arrow between them — for a fact “inexpressible in one frame at any window count”, and exactly one claim ever cleared it: Scrolling’s follow anchor, where the viewport pans the minimum to reveal the newly focused window. A still picture cannot show motion.

The premise is true and the conclusion does not follow. A pair does not show motion either. It shows two states and asks the reader to infer the tween — a second inference stacked on the one the still frame already asked for. Against that inference it charges double the width in a chooser whose whole job is comparison at a glance, an arrow drawn nowhere else in the family, two sub-captions, and two more strings in eleven catalogs. The caption states the same fact in one clause, in the reader’s own language, and every schematic already has one.

What breaks without the rule is the chooser. A strip of thumbnails reads as a set only while the tiles are comparable; the moment one is twice the width of its neighbours it reads as broken rather than as special, which is precisely how the Scrolling tile read. And the bar itself cannot be applied consistently, because every transition is inexpressible in a still frame and none of them is expressible in two — so the bar was never a test, only a description of the one case that had already been built.

The cost is named rather than argued away: two picker options now draw the same picture. follow pins the focus nowhere, so the only resting position it can honestly draw is the neutral one — which is center’s, to the pixel. A reader who clicks between the two sees the frame not move. That is accepted, because the preview’s subject is the rest state and at rest the two genuinely are the same layout; what separates them is behaviour under motion, which no frame of any count denotes. So the caption carries it, and the caption switches on the anchor — the words are what changes when the picker does, and they are also the only place follow can be described at all.

That last point generalises, and it is the obligation the trade buys: one string spanning a picker’s options states the odd option’s fact under all of them. Every option that lacks the fact then carries a sentence about one that has it, and the a11y label asserts it over a frame that was never drawn — a shared caption is not a saving here, it is a false claim on three settings out of four. Where two options genuinely draw the same picture, a shared caption is also the one thing that would make selecting one of them do nothing at all.

Two facts a caption may not claim, both because the frame is finite. The insertion + is the shape of what a still frame answers perfectly — “where does the next window open” is the question the preview exists for — but Scrolling’s row runs several canvases wide at most window counts, so the incoming slot is frequently clipped away: at the default five windows with New window ▸ Last it already is. The caption’s + clause is therefore conditional on the row actually putting the mark on the frame, and stays silent otherwise, the way Stack’s, Grid’s and Monocle’s captions are silent about theirs in every case. And the corollary about scale: a thumbnail spends its whole canvas on the layout. Scrolling reserved a margin of its frame for the ghosts of windows past the monitor’s edges — a real fact, readable in the side panel, an unreadable sliver on a tile. Reserving the room there drew the monitor at half the scale of every sibling’s outline, so a tile meaning “this layout continues past the screen” said “this preview is broken” instead. Draw a fact at the size it can be read; at the size it cannot, do not draw it at all — leaving it to the frame’s clip is not the same thing, for the reason SchematicCanvas.screen states: the clip does not crop where a reader would assume.

Home is the only navigator: a card grid, not a sidebar. (#678 turn 9, superseding the #68/#297 fixed source list.) A sidebar is a menu the user pays for on every visit — a column of names that answers nothing until clicked. Home inverts that: each destination is a card carrying its current values (and, where a real renderer exists, a small live picture), so “is my gap 8 or 10?” is answered without opening anything, and the window opens as a status board rather than a launcher. An area screen is a pushed view behind a ”← Home” back chip (⌘[ and Escape pop it; Escape only when no inner view claimed the key). What this buys over the sidebar it replaced: the full window width for content, an entry screen that scales to the mode’s card count instead of a fixed taxonomy column, and per-language label budgets that die with the fixed column (a card flexes; an over-long label truncates visibly). What it costs, accepted: a second click to move between sibling areas until the pill row lands (Phase 4). The old HStack-not-NavigationSplitView finding stands as history — macOS 26 cannot lock a split-view divider — but nothing composes columns any more, so nothing rests on it.

Home’s two groups are scope-named: “This Profile” / “Whole App”. (#678 turn 9, reversing the #68 “Design”/“System” topical naming — deliberately, not by drift.) The topical names existed because a sidebar label had to predict placement for a user who couldn’t see the contents. A card grid shows the contents: every This Profile card renders values from the draft the header’s profile chip names, so the scope label is no longer a prediction the user must make — it is a caption over evidence. Scope was always the primary navigation axis (turn 2 kept it through every concept); naming it honestly beats a topical alias once the cards carry the proof. SettingsDestination.thisProfile / .wholeApp remain the membership’s one copy, and HomeCardOrderTests pins the grid’s groups to them.

A card’s picture asks the real data — reusing its editor’s own maths where one exists, staying a data readout where none does — and is never a sketch drawn beside either. (#678 turn 9; the desktop plate #786, owner rulings 2026-08-09, superseding turn 9’s text-only fallback.) A hand-drawn stand-in ships a preview that teaches what the app does not do — the #702 class of defect, at grid scale. So where an editor already owns the maths, the card calls it: the layout schematic family with its SchematicPlacement splice, GapPreviewScale.mini and FocusBorderPreview’s width remap on the Gaps & Borders tile, MonitorArrangement.layout, BarsGates’ own shown-bar predicate. Where no editor maths exists, the picture is a readout of the draft, never a decorative sketch: one pane per declared space, the colour fan and swatch grid of the config’s real hexes, the Behaviour divider answering the real mouse-resize choice. Turn 9’s “or the card stays text-only” was this same rule under scarcity — the unified dark preview tile did not exist yet, so cards without a renderer went without a picture; #786 built the tile, and the fallback retired with it.

That tile is the desktop plate: a profile card’s picture is a picture of the user’s desktop, so it sits on a fixed desktop-dark ground (SettingsTheme.previewPlate, identical in both appearances — what the picture shows must not change with the window’s appearance) and draws in the USER’s palette read from the draft, not the brand accent: brand describes the app, profile colours describe the desktop. Two follow-on rulings, both 2026-08-09:

  • Two card heights, derived from the one group partition. Profile cards hold the plate band; whole-app cards sit compact, because their previews are rows of data — key caps, profile chips, app icons, the version — that belong beside the title, not on a desktop. Each group thereby reads as a uniform grid, and “which cards are tall” is read off HomeCardOrder.thisProfile, never a second hand-kept list. HomeCardChromeTests pins the heights pair and the plate’s geometry.
  • The palette fold floors against the plate. The plate is KiwiDesk’s fixed ground while the palette is the user’s, and a legal palette (Lua is open) can carry a colour legible on its own bar yet invisible on this ground — so a user colour that sinks into the plate swaps for a theme fallback rather than drawing dark-on-dark, guarded by HomeCardChromeTests.

The Simple/Power User segment gates whole cards, and navigation into a withheld card switches the mode rather than refusing. (#678 turns 4/9.) Mode depth is per area (SettingsArea.minimumMode) — never per row, and never an input to anything that resolves behavior. Monitors is the one COMPUTED promotion: it joins Simple while 2+ displays are connected, decided at read so a disconnect cannot strand a stored flag. Search and cross-references index both modes, so a landing in a Power-User-only area flips the segment (visible in the header) instead of dead-ending; flipping Power User → Simple while standing in a Power-User-only area pops to Home, because the area ceased to exist — mode gates cards, so this is the “which cards exist” rule, not a grey-don’t-hide violation. The pick persists like the appearance choice (UserDefaults, absent = Simple, never gui.json — a sidecar write would flip config ownership).

The flip to Power User answers with motion plus an accent-tinted weight — the mode’s own colour, at reduced strength. (#760, amended on device 2026-08-09.) Without an answer, the flip changes the page silently and users toggle twice to compare — the tell that the transition carries no information. So the moment washes the containers it inserted (the search reveal’s own transient accent wash, on title bands alone — a per-row control offer the mode also unlocks has no border to weight, and washing a dozen sibling rows is shouting, so those appear plainly and stay enumerated as data), the pane the user is standing in animates the insertion, and the durable half is the frame: 1.5 pt of the accent at modeGatedStrokeOpacity against the 1 pt hairline rest.

The first cut of this ruling said “weight, never a hue”, and its premise failed on real hardware: a weight step on the hairline is a step in a stroke whose contrast is ~1.2:1 — invisible in both appearances — and a stronger neutral read as “different” without saying which different. The frame borrows THE accent, not a second hue, and that distinction is what keeps the original argument’s teeth: no ok/warn misread, because the accent’s meaning here is “the mode whose segment wears this colour” — the wash paints it on exactly the containers that then keep the tinted frame, so the continuity from wash to frame to the active Power User segment is the legend, drawn nowhere. Hue still never carries alone: the weight step stays, and the strength is measured, not felt — 0.5 sat exactly on the CVD separation floor against the light hairline; the shipped value clears the hairline it neighbours on that edge over both grounds a card is drawn on, in both appearances, and ModeGatedFrameSeparationTests derives those floors from the shipped tokens. The weight stays below the doubling the Monitors (1.5→3 pt) and palette (1→2 pt) pairs spend on selected/applied — a mode-gated card is present, not picked.

A persistent statement and a transient one never share a property. (#1173.) Hover first took the full-strength accent on that same edge, on the reasoning that the marking’s own strength therefore had a ceiling. Sharing the property was the defect, not the strengths: the two states then differ only in degree, so a marked card reads as permanently hovered — and pointing at a marked card OVERWRITES the one channel carrying its marking, which no ceiling can fix, because a statement a pointer can delete is not a statement. So the channels split by what they answer: the border says which mode a card belongs to, the fill says where the pointer is (cardHover, card stepped one notch toward the ground in each appearance — opaque, so the inks above it are measured against what they actually sit on). The two facts compose, which is the whole prize: a marked card under the pointer keeps its frame AND lifts. It also returns the Home card to the idiom the rest of the tree already keeps — a neutral lift for the pointer, the accent reserved for what a thing IS.

Only the user’s own flip glows; the implicit promotion stays silent. (#760.) A search or cross-reference landing that flips the mode is a means to a destination the user already named, and that landing owns its wash — a second, simultaneous mode wash would dilute the one they asked for. The reverse trip draws no attention at all: content that is leaving is not worth pointing at, so Power User → Simple is a plain fade. And the mode-gated flag is computed from the site’s own offer predicate at .simple, never remembered or hand-negated — so the border states the same fact as presence on exactly the surfaces where that fact moves (Monitors on a laptop vs a desk, the Layers card before and after the first layer). Under Reduce Motion the wash shows flat and the reflow stands down — dropping the wash entirely would take the answer from precisely the users who lose the motion channel. ModeGatedChromeTests and SettingsModeRevealTests hold the two halves.

One draft, three views — and the save surface is the floating pill. (#678 turn 9; Phase 4 shell.) The turn-9 frame draws three views of one draft on one screen: the floating save pill, the change-list popover, and the detail panel’s “Changed in this draft” list. All three ship now. The popover opens from the pill’s own count line, not from a header chip (owner 2026-08-10, retiring the turn-9 top-right button): the pill already narrates the draft, so a second count in the header stated one fact in two corners of the window — and every corner the count occupies is one the responsive pass must defend at 720 pt. An earlier ruling kept the docked three-verb footer, arguing it was the pill’s final form since the spec’s responsive pass would dock the pill below 900 pt anyway — the owner overturned that on sight (2026-08-09): at every width this window actually opens at, a full-width bar under the content reads as chrome that is always there, while the pill exists exactly when the draft does, which is the fact the surface is FOR. So the pill floats over the content column, centred on it (offset past the preview panel when one is open), appears only while there is something to act on, and disappears at zero — the one deliberate exception to grey-don’t-hide, carried by GreyOutHidingTests. Below 900 pt it docks into a real footer bar (turn 17a, shipped): the pill’s argument is that it costs a strip of gutter and covers nothing, and below 900 that second half stops being true — the same pill sits on top of the rows it is about. So the pill is the form, not the fact; what survives both forms is that it exists only while the draft does. It is the one component in the shell that changes KIND rather than size, which is also why it stays ONE view with two containers: two footer types is two places for one draft to be described differently. The shown N is the ROW COUNT of the list it opens (owner 2026-08-10, revising the earlier settings-count ruling): a per-instance family expands one census setting into a row per touched instance — three space modes changed is one setting, three rows — and a count a user can cross-check against a visible list must be that list’s count, or the surface reads as dropping changes. Many leaves under one ROW still count once (SettingsDraftDiff resolves leaves to census settings; the readout expands instances), and the dirty-tracking draftChangeCount keeps the settings granularity internally — only surfaces beside a list state the list’s number. The popover and the panel list are no longer partial — the readout (SettingsValueReadout) narrates every attributed key, and its totality guard is what discharged the earlier partial-list objection — so each row states old → new and jumps to the control that changed. Save and Revert live in the pill alone.

The detail view is two columns, and the panel is where the draft is watched. (#678 Phase 4.) An area that has something to show gets a fixed 392 pt right column: “Live preview · ” over the area’s preview drawn from the DRAFT, then the diff list. The redesign spec’s collapse handle is deliberately NOT built (owner 2026-08-10): the window drops the panel by WIDTH below 1200 pt (turn 17a), and a manual collapse beside that is a persisted preference duplicating what the window already decides — DetailPanelTests pins the absence so a handle quietly returning must re-argue it. The detached card’s close button is not that handle and the difference is one stored key wide: it is per-mount state, cleared on every navigation, so it answers “not on this screen, right now” rather than “this area has no preview” — an answer that outlived the window growing back past 1200 would leave a docked column the user could not explain. Which areas offer one is a single data set (SettingsDetailPanelOffer.offering), because the prototype’s rule is a verdict either way: an area with nothing to show hides the panel and takes the full width — absence must be a decision, never a missing branch. The panel’s previews are existing renderers moved or recycled, never new drawings beside them: the schematic-and-slider, the palette scene, the gap miniature with the ring and the drag ghost, and — for Bars — the Home plate’s fused two-bar desktop scene mounted at panel size (the two card strips retired with their cards); Shortcuts later joined with the keyboard board (pass 5), a panel-first renderer with no card twin to retire — the rule bans duplicates, not first previews. The in-card mounts are REMOVED in the same change — one screen must not state one fact twice — which NARROWS the old “live preview leads its editor” convention to areas without a panel: in the panel areas no migrated preview survives in its card (DetailPanelTests holds the offer set and the removals both). Advanced Colours was the worked exception while it had no panel of its own; #793 gave it one, and its four group previews left under this same rule rather than by a new decision — the condition changed, not the convention.

The panel’s object is the DRAFT, so a picture of anything else is not a panel — it is a sheet. (#859, owner 2026-08-16.) Preset cards raised the case: a card can identify a preset — its name, how many screens it plans for, one glyph per screen — but it cannot describe one, and Apply is consequential, so the only way to find out what a preset contained was to apply it and read the result. The panel is where this window already puts depth, and it is the wrong place twice over. Its column is headed “Live preview · ” and redraws from the staged draft, while a preset is a catalog entry the draft has no say in; the two differ exactly while a draft is open, which is when the heading would be lying. And the column is not always a column: by “Narrow windows drop the preview before they drop a control” below — which owns the bands, and whose promise that the preview loses its column and never its existence still holds — a narrower window hands the same content over as a card the reader summons and then drags out of the way. That is the right trade for a preview redundant with the controls beside it, and the wrong one for a picture that IS the answer, whose delivery should not turn on how wide the window happens to be. A sheet inverts both: its object is whatever it was handed, and its size is its own.

The general rule this settles, so the next surface does not re-argue it: choose the container by whose object the picture is. Draft → the panel. A catalog entry, a stored file, anything the user is not currently editing → a sheet off the thing that names it. That is also why the sheet stays read-only: the moment it grows a commit it is a dialog, and the commit belongs to the surface that owns the object. Profiles therefore joins no SettingsDetailPanelOffer.offering and its cards owe no preview migration — DetailPanelTests pins that refusal with all three grounds, precisely because the redesign prototype drew a panel here and a later reader would otherwise take the prototype for the ruling.

One consequence worth stating because it is not obvious: the sheet draws from the preset’s own TilingSettings, never the draft’s. Drawing it from the draft would make the picture change when the user edited something unrelated, and would quietly turn Profiles back into a draft-preview surface — the one line the whole ruling above rests on.

A composite question needs a composite picture, in the colours the user actually set. (#793, owner 2026-08-16.) Advanced Colours edits twenty-five colours in four groups, and four group previews can answer “is this row right?” while leaving “do these work together?” unanswerable without saving and looking at the real desktop. The accent ladders, the two rings, the state marks and the drag pair are judged against each other. So the panel draws one scene holding every role at once — which reverses that page’s own earlier reasoning, recorded here because it was argued rather than merely outgrown: grouping by where you see it decides the ROWS, and never settled whether the PICTURE should be per-group.

Two constraints the scene is built under. It draws from raw palette paths, never the Home plate’s accent/ink/base fold — a fold is right on a card and exactly wrong on the one page whose subject is per-role tinting, where a focus ring the user has made illegible must read as illegible. And it omits the four hover roles: a still frame can only draw a pointer state as the resting one, which would teach a behaviour the app does not have (#708’s defect wearing another hat). That omission is data with a reason attached (PaletteSceneRoles.withheld) rather than a caption disclaiming itself, and PaletteSceneRoleTests holds every palette path to being drawn or argued away — so a new colour cannot quietly miss the page that exists to show them all.

Wide windows cap the content, not the panel. (#678 Phase 4; owner 2026-08-10.) The prototype was drawn at 1440 and the breakpoints only go DOWN, so full screen was unspecced — and an uncapped content column stretches every row past readability while the panel’s previews are pt-tuned at 392. So the content column stops at 980 pt (the widest column the prototype drew) and centres in the surplus; the panel stays 392. Home follows the same instinct at its own scale: never more than the redesign spec’s four columns — on a big screen the CARDS grow instead of a fifth column appearing. Whether the panel should take some surplus at extreme widths is an open follow-up, not a decision this entry makes.

Narrow windows drop the preview before they drop a control. (#678 turn 17a.) The window is the user’s to make narrow — the Settings window tiles like any other (#678 item 18), can be floated like any other, and a hand-drag reaches the same widths either way. So the question is not whether something gives but in what ORDER. Three things can: the preview’s column (1200), the row layout (900), the header chrome (820) — in that order, and controls never. The order is the whole ruling. A preview is a convenience that repeats what the rows already say; a row’s control is the only way to change anything, and a segmented control that wraps or a menu clipped to its chevron is worse than a preview you have to move. Below 720 the window stops resizing outright, because a settings window narrower than that is one where every row is two lines and nothing is comparable — a minimum size is a kinder answer than a layout nobody wants.

Three consequences worth stating, because each was reachable another way and rejected:

  • The preview loses its COLUMN, never its existence. It detaches into a card over the content — draggable, and clamped so it always lands whole inside the window, since a card dragged off a 720 pt edge has no scrollbar or menu to bring it back. Between 900 and 1200 the card opens with the screen; below 900 it waits behind “Show preview”. One card, two defaults — not two features — so that an area offering a preview always has exactly one way to it.
  • The reflow swaps the LAYOUT, not the view. A row below 900 puts its label above its control, and it must stay the same row while it does: tear the subtree down at the breakpoint and an open menu closes, a focused field loses focus, and the user dragging the window edge sees the app flicker rather than reflow.
  • The chrome step costs the TITLE, and only while searching. Below 820 the header’s search field collapses to its glyph; opening it takes the row from the area title, because at that width the two cannot both have it and a field squeezed to what is left is not a search field. The title is back the moment the search closes, and that is the whole extent of the cost. Nothing else in the header yields, because everything else in it is a control.

An inline disclosure row leads with a thin rule. (Owner 2026-08-10: the App Bar’s Style accordion was nearly overlooked among plain rows.) The prototype draws every inline disclosure row with a top border — the rule is the “different kind of row” signal, separating the accordion and whatever follows it without promoting the drawer to a card. And what a drawer reveals sits in ONE sunken well, never one well per row of its contents.

A drawer header is a whole clickable row, not a triangle. (#956, owner 2026-08-23; the second round on the same complaint.) The rule above says “different kind of row” and does not say openable, and the native disclosure header answers only its own small triangle — so the header under-signalled and then under-delivered when a reader clicked the label it does signal with. Both halves are one seam, SettingsDisclosureStyle: the header becomes one full-width .plain Button over the whole row, and it rests on a chevron with real weight that rotates on expand, confirming on hover. The button is preferred over a tap gesture on the label specifically because it is a control — it takes one focus stop, Space activates it, and macOS keyboard navigation can reach it, none of which a gesture offers. (Space, not Return: Return belongs to the window’s default button, so a ruling that promised it would be promising the platform’s behaviour rather than ours.) The cost is what every custom control here costs and must be paid back in the same change: a Button is not a disclosure triangle, so VoiceOver stops saying whether the drawer is open, and .accessibilityValue gives expanded / collapsed back (the LinkedCaptionHitTests rule, generalised). One style, both chromes and the one drawer outside the wrapper: a header that reads as openable in a card and not inline would be the same defect wearing a different frame.

The cue is the chevron and the hover, never a resting fill. The first build wore the house icon-chip cue — a 0.06 rest fill — and the owner read it on device as “the grey doesn’t fit the kiwi design” (2026-08-24). They were right, and the arithmetic says why it is a HUE fault rather than a strength one: that fill is Color.primary, so on a light card it composites to #F0F0F0, R=G=B exactly — the one achromatic surface in a window whose every other surface and border is green-tinted. It measures 1.14:1 against its card, faint enough that pure lightness at that step would be invisible; it was seen anyway. Beside a sunken well of almost the same lightness, a hue difference at equal value is maximally salient, and the collapsed header even sat darker than the interior it opens onto.

The fix is not a better-coloured fill, because no fixed surface token can be a rest cue for this style at all: it draws on three grounds, and the obvious candidate — sunken, which is green-tinted and would answer the nested-well objection, a fill without a hairline being no well — is invisible on the third drawer, whose card already IS sunken. A cue that works everywhere has to be ground-relative, which leaves Color.primary (achromatic — the objection) or the accent, and a resting accent wash on every drawer says “selected” about nothing while spending the one channel a green primary cannot carry under colour-vision deficiency. So the rest state paints nothing, hover confirms at the full-row ladder, and the resting “I open” signal is the chevron — at ink2, since with no fill the chevron IS the affordance and ink3 is the caption tier. The general rule: an affordance’s recipe is chosen by AREA. The icon-chip cue and the full-row cue are two ladders for two sizes, and taking the smaller one to a full row is how a token nobody notices becomes the most visible thing on the page.

The third round, and what the first two actually bought. (#1021, owner 2026-08-25: “in some menus the accordion is way too small” and, asked which, all of them.) The complaint came back because #956 fixed the hit target and the announcement while leaving the two things a reader sees. The chevron it introduced “with real weight” was pinned at .footnote — the smallest step on the ramp — so the argument above (“the native triangle is drawn at the system’s own small size, which is what made it easy to miss”) applied verbatim to its own replacement. And the header’s SIZE was a call-site decision: Chrome carried a font: payload, so one component drew its title at four tiers and seven of the fifteen drawers were drawn smaller than the rows they head. A header quieter than its own contents is not a subtle cue, it is an inverted hierarchy.

Two changes, and they are a pair because each reaches a half of the surface the other cannot. The tier stops being a call-site decision: Chrome loses its payload, both chromes draw one tier — SettingsDrawerHeader.tier, named so the title and the summary beside it cannot drift apart — and seven call sites lose an argument. Which tier it is was always the tunable half; that there is one is the half that must not drift back, and SettingsDisclosureSizeTests is what holds it, for wrapper users: the drawer built outside the wrapper takes the style’s row without the wrapper’s tier, and keeps its own quiet title deliberately. It is .callout at semibold — 12 pt, a point under the .body rows it heads, carrying the header on weight rather than on size. It stood at .headline (13 pt semibold) for a round and the owner read the result as heavy, most of all on the two pages that carry seven of the fifteen drawers between them. There is deliberately no “bigger” available either way — macOS’s ramp runs body 13 → headline 13 at weight 0.4 → title3 15, so below 15 “bigger” and “weightier” are the same edit, and the only genuine size step is title3, which is SettingsGroupHeader’s tier and would outrank the section title an inline drawer sits inside. And the indicator is sized by the header it marks, taking no font and no scale step of its own: bold at the title’s own size, about 12 pt, shrinking to about 10 pt on the one deliberately-quiet drawer. Proportional by construction, so a future header move carries its chevron along instead of stranding a constant.

Weight is the only step the indicator takes, and that is the second half of what read back as heavy. It wore .imageScale(.large) on top of the inheritance for a round, which made the chevron larger than the title it marks — the biggest thing in the row, on a row whose job is to be found without shouting. An indicator that outgrows its own label has overshot the same way a header quieter than its rows undershoots. The five .card drawers are the reason the pair was needed at all: their labels already sat at the tier the other ten have only now joined, so the tier change alone reached nothing in them and the indicator is the only thing that ever moved there — which is why the owner’s “bigger text or a better indicator” was a false choice.

One clause of the ruling above is narrowed rather than inherited, since it was argued absolutely from a single candidate. “No fixed surface token can be a rest cue at all” was tested on sunken; the untested one is hairline, which is green-tinted and so answers the achromatic objection outright. It fails for a different, measured reason — about 1.05:1 against sunken, invisible on precisely the drawer whose card already is sunken. Same verdict, honest premise. And a resting fill is refused on a second ground the first round did not state: fifteen headers wearing a band would make the drawers the most-filled things on pages whose real controls carry no fill at all.

A drawer’s summary belongs to the row, not beside it. The same round’s third complaint was the text that states what a shut drawer hides: five call sites drew it by hand at .font(.caption), 10 pt against a 12 pt header, and four of the five wrapped their own shut-only if while the fifth did not — the header tier’s drift wearing a different slot. One summary: slot now owns the words, the tier and the rule.

Two placement rulings fall out, and each is the interesting one. It renders inside the header button: the ruling below puts the accessory: slot outside because that slot may hold a control, and a control inside a control loses its click and its name — a summary is plain text, so the argument does not reach it, and drawn outside it cost the row the two things it is, the hover highlight ending before it and the words that describe the drawer not opening the drawer. And it stays beside the title rather than moving under it: it states the drawer’s current VALUE and is gone the moment the drawer opens, where a caption explains what a thing IS and stays. Under the header it would add and remove a line on every toggle, in the space the drawer’s own contents occupy — and made persistent to avoid that flicker it would simply be a second caption on a card that already has one.

Its ink is ink3, and that is the chevron’s own argument read the right way round rather than an exception to it. The chevron takes ink2 because with no resting fill it IS the row’s affordance, and an affordance outranks the caption tier. A summary is description, which is what ink3 is for; a whole phrase of it at the chevron’s darkness competes with the title it supports.

Every drawer is a heading. All fifteen were absent from the VoiceOver headings rotor until this round — the same “cannot find it as one” complaint on the channel no number of points can answer — so .isHeader goes on the style’s button, where it reaches the one drawer built outside the wrapper too. (gui.md already required it of a title component; nothing had noticed that a drawer title is one.)

The header’s accessory is a SIBLING of that button, never its child. A drawer’s accessory: slot may hold a control — the Profiles-per-Desktop drawer puts its ? there — and the first draft of the style wrapped the whole label, accessory included, in the header button. A control inside a control loses both halves of being one: the click lands on the outer button (the ? toggled the drawer instead of explaining it) and the inner name and hint collapse into the outer element’s single announcement. So the row’s hit shape stops where the accessory begins, which is also what makes the drawer’s disclosure label still usable as a live help anchor. That puts the accessory at the row’s TRAILING edge, where it sat beside the title before: the button keeps the Spacer, and the only way to return the accessory to the title’s side is to end the button at the title — which gives back the full-row hit target that is this whole ruling’s point. Trailing is also where macOS puts a row’s accessory, so the pair of “click anywhere” and “the accessory is on the right” is one idiom rather than two compromises. The general rule this instance serves is already written down — two controls in one strip are two accessibility elements — and the lesson is that wrapping a slot whose contents you do not own silently breaks it.

Census labels render at runtime from the English manifest. (#678 Phase 4.) A surface that renders a census-labelled key AWAY from its owning row — the diff rows, and the search index after them (#678 turn 11) — cannot inline the English without becoming a second authoring surface that drifts from the rows. So SettingsCensusLabel resolves the current locale first and falls back to the en.json manifest scripts/extract-keys regenerates from the live call sites. That amends en.json’s build-time-only contract deliberately (stated on LocaleCatalog): the manifest is regenerated on every key change, so its staleness class is the same as any bundled catalog, and the alternative — 200-plus English literals in a second switch — is the drift this repo’s one-list rules exist to prevent. The VALUE words in those rows obey the same one-authoring-surface reason: where exactly one picker family owns an enum option’s words, the diff row reuses that picker’s key verbatim — key and English — so the diff and the control it jumps to cannot name one choice two ways in any locale (SettingsValueReadout states the rule on its Layout labellers; PlacementPicker’s words, reused verbatim, are the worked case). The orientation pair is the stated exception: TWO picker families spell Vertical/Horizontal (layout_params.orientation.* and scroll_grid.*), one diff key cannot match both, so the diff keeps its own pair spelled identically.

The first-run banner orients once, then gets out of the way. (#678 turn 14c.) Home opens already full — the tour seeded a real setup — so the first visit gets a banner saying so (“You are already set up”), never a wizard or an outlined empty state. It seeds when the tour reaches its closing beats, and retires permanently on dismiss or on the first clean transition of a dirty draft (save, revert, or a confirmed discard): a user who just acted on their settings is past needing orientation, and a welcome that lingers becomes chrome. “Show me around” is the welcome tour’s first voluntary entry point — the other callers are all involuntary (permission loss, discovery resume) — so replay starts at the top rather than at the step a trigger needed.

Live-apply is the rare exception, earned per control — not per tab. (Settled 2026-07-10, full-Settings audit; #123.) A control stays staged behind Save unless it clears one of two bars: (a) it owns no profile state at all (the General ▸ Language picker persists straight to UserDefaults, never gui.json — there is nothing to stage), or (b) its feedback loop is the live runtime and no in-window simulation can substitute (the keybinding recorder: the only way to know a shortcut works is to press it). Everything else — sliders, colors, pickers, placement grids — stays staged; where a raw value is hard to judge, build an in-window preview (the GapsDiagram / DragVisualsEditor-strip pattern), never live-apply. Sweep verdicts: Spaces, Behavior, App Rules, Shortcuts (minus the recorder), and the Desktop→profile bindings are plainly staged. Monitors’ drag-cards and the icon pickers are self-previewing (the control is its own preview — a third category needing neither live-apply nor a bolted-on preview). Profiles-section rename/delete/make-default/preset-apply are immediate file actions, not settings — correctly outside this question. The Spaces tab’s per-space layout picker stays staged. No control besides the key recorder passes the live-apply bar.

Three save verbs: Revert / Save a copy… / Save. The old footer showed up to seven differently-labeled verbs depending on invisible mode state, but they expressed only two intents: “persist to what I’m editing” and “duplicate under a new name”. Three stable slots — a consolidation the floating pill inherits whole, verbs and per-mode semantics alike (see “One draft, three views” above). The header’s profile picker names the edit target authoritatively — a destination caption beside Save duplicated it, read as confusing, and its fixed width split the button cluster apart, so it was dropped. Adopt is not a save verb — it lives with the raw-Lua content it migrates. (#68 §3.12)

The edit-target dropdown lists the loaded profile as its own row — no collapse to Live. (#209.) The top Live entry edits the running/global config; every saved profile lists below, the loaded one included. Picking the loaded profile used to silently remap to Live, which made it the one profile whose stored sparse overrides (key layers #55, app rules #109) could never be edited — you could only touch the live/global config. The considered fix — listing the loaded profile twice, top meaning global and list meaning overrides — was rejected as a menu anti-pattern: the ✓ can’t disambiguate two identical rows, the closed title goes ambiguous, and the discard guard keys on the profile name. Instead the rows are already textually distinct (Live (currently loaded) vs Name (currently loaded)), so the collapse is simply deleted and each profile is one real .storedProfile target. Editing the loaded profile is the sole target whose Save hits the screen at once: saveEditedProfilereapplyIfInEffect re-applies it in place (no switch), because it is the layout on screen — so its status caption drops the generic “changes won’t switch your layout” for a truthful “saving re-applies Name with your changes”, and the closed menu title reads “Name — overrides” to stay distinct from Live-with-that-profile-loaded.

The two doors write different layers, by design. #209 makes the loaded profile reachable through both the Live entry and its own row, and the two saves touch disjoint field sets of the same file — intentionally, because they edit different layers of the sparse-override model, not the same data twice:

  • Live Save (updateActiveProfilepersistProfilebuildProfile) adopts the live tiling state (spaces, spaceModes, mainSpaces, fallbackSpace, settings) and deliberately preserves the profile’s stored modes, appRules, floatRules, and ignoreRules — those are sparse diffs against the global base, and Live editing changes the base (gui.json or init.lua), never the diff.
  • Override-row Save (saveEditedProfileoverwriteProfileapplyProfileEdits) writes the profile’s sparse behavior diffs (against the matching global bases) plus its tiling — this is the surface that edits the diff. Ignore rules have no GUI control yet, so that hidden diff is preserved verbatim rather than reconstructed from resolved state.

So “Live leaves profile.layers frozen while the row rewrites it” is the model working, not divergence: one door edits the base, the other edits the per-profile diff over it. The trap to avoid is “fixing” buildProfile/persistProfile to also adopt the behavior overrides — that would collapse the diff into an absolute and silently break the sparse override. Pinned by ProfileSaveAsymmetryTests so a future edit that erases the asymmetry fails red.

One header bar: section title leading, profile picker trailing; status only when non-nominal. The section name and the profile edit-target picker are related facts (what am I looking at / in which profile), so they share one titlebar row instead of a title stacked over a separate profile banner. The picker moves into a trailing toolbar item, shown everywhere except General (showsProfileContext) — App Rules keeps it because its rules target profile-scoped spaces (and, since #109, its Space facet is itself per-profile-overridable). The status sentence is demoted to a conditional strip that mounts only when there’s something non-nominal to say (divergence, unsaved, built-in, no-match, or a warning) — a synced profile says nothing, so the common case is a single bar and content scrolls straight under the blurred titlebar. (#68 §3.1)

“Unsaved changes” is a live comparison, not a latched flag. isDirty compares the edited config and Lua source against the as-loaded baselines on every change, so manually undoing an edit clears the save pill again — a latched flag kept claiming unsaved changes after the user had already put everything back.

Every edit-dropping action routes through one discard gate, and the guard over it is a lens, not a list. Seven Settings actions ended in reload() — which re-seeds from disk and clears isDirty — and six of them dropped the user’s staged edits with no prompt at all. They now share SettingsModel.discardingEdits, which runs the action when clean and parks it behind a single dashboard-wide dialog when dirty.

The gate lives at the call site, not on the model methods. The alternative — a required confirming: parameter on loadProfile, deleteProfile, … in the shape of apply(profile:forceRetile:) — would be compiler-enforced rather than test-enforced, and is the stronger design on that axis. It was not taken because two of the seven paths are not method calls at all (showLuaEditor is a @Published flag, and leaving the raw editor is a two-statement composite), and because self-gating methods double-prompt the moment one confirmed gesture calls two of them. The cost is accepted knowingly: the model methods stay individually unsafe, and a source-scanning guard stands in for the type constraint. Revisit if a third surface outside Sources/KiwiDesk/Settings ever drives one of them.

The guard discovers, it does not enumerate. It walks delimiters to extract every discardingEdits trailing closure, then requires every occurrence of a destructive model.* call to sit inside one. A hand-listed “these seven are gated” would be fail-open for the case that matters — an eighth path absent from both the list and the index is never examined. That is not hypothetical: discovery found a seventh path the #406 audit’s own hand-traced list had missed (the broken-profile Delete). An eighth, adoptIntoGui, was found by a reviewer, not by the guard — it had no token for that call. Recorded because the difference matters: the guard covers what it has needles for, and adoptIntoGui now carries one.

Deliberate exceptions, so they are not “fixed” later. The pill’s Revert is unconfirmed on purpose — the verb is the confirmation, and that is the macOS norm. Adopt keeps its own dialog instead of stacking the shared one, so one gesture prompts once; that dialog names the dropped buffer itself when dirty. Reopening the window (SettingsWindowController.show) guards with if !model.isDirty rather than prompting, because reopening is not a user action against their edits (#455). The menu-bar Load Profile and the Config Issues delete go straight to the core and never reload() the model, so they drop nothing.

The dialog offers two verbs, not three. macOS document apps offer Save / Discard / Cancel with Save as default. An unconditional Save is not offerable here: with no profile yet the primary action is “Save as New Profile…”, which needs a naming sheet, and profileSaveBlockedReason can block saving outright while Accessibility is off. Discard / Cancel is the honest reduction. Adding the third verb later means changing PendingDiscard and every call site — decide before doing it, not by accretion.

A paused engine blocks profile saves, never global ones. The #335 gate exists for one reason: with Accessibility off no displays are discovered, so persisting a profile would record a degenerate 0-screen monitor set that can never resolve. It caught the six gui.json globals — keybindings, app/float/ignore rules, the space list, Desktop→profile bindings — as collateral, purely because saveGuiConfig had exactly one caller and that caller sat behind the gate. None of the six has a monitor dependency, so a user editing app rules met a greyed Save explaining monitors, and lost the work on close.

A fifth PrimarySaveAction case (.saveGlobalsOnly) takes the primary slot while paused and a global actually changed. Three things that are load-bearing rather than incidental:

  • Ahead of the two profile verbs only. .saveLua and .updateStoredProfile write no monitor set either, so they were never blocked and must not be rerouted. Save a copy… stays unconditionally gated — a copy always captures the live set.
  • Its own narrow method. Routing through persist(named:) would drag in the overlapping-monitor-set warning and a “Saving failed” message naming a profile the save never touched.
  • Partial-clean, never reload(). Only the six fields are adopted as clean; a blanket reload would discard staged tiling edits this save did not persist, and with both pending the pill must stay up, still counting the unsaved changes.

The copy names what is excluded (“Layout and monitors stay paused; Save covers everything else”) rather than listing six field names — one sentence, and the paused banner above already carries the why. The blocked tooltip’s monitor wording is unchanged and is finally accurate: it now appears only when a monitor set really is the only thing a save would write.

Quick-menu layout switch is session-only, and Settings does not narrate it. Changing a space’s layout from the status-bar quick menu updates the running state immediately and writes nothing: users try Monocle for ten minutes without rewriting their configuration. Keeping it is one row away — Keep Layout in Profile "" — and Keep, not Save, is the word, because macOS already uses it for exactly this shape: a change that is undone unless you say otherwise. That row arms when ANY screen’s shown space stands on a temporary layout, not just the focused one, since the verb writes the whole profile; a failed keep (a screen-count mismatch) raises an alert, the menu having no surface to warn in.

The two write paths mean different things, and that is the whole design (#1179).

  • Quick-menu Keep = a whole-live snapshot. “Write down what is on screen.” It takes every screen at once, and it is the only thing that turns a temporary layout permanent.
  • Settings Save = a draft commit. “Save what I edited.” It applies and persists the modes of the spaces the draft actually edited, and nothing else. It never re-asserts the profile’s modes wholesale — that is Revert’s meaning, and a Save carrying it destroys the very layout the user was about to keep. It never captures live either, which would adopt a temporary layout nobody asked to keep.

Each half fails in a way the other hides. A Save that re-applies the draft over live and then captures live back restores the previous layout and saves that — Save behaving exactly like Revert, destroying the change it advertised. A draft that seeds its per-space modes from LIVE writes a temporary layout into the file on any unrelated Save. So the draft seeds its modes from the SAVED profile, and live supplies only what is live’s to state: which spaces exist, their order, their pins and the Main role.

“Edited” is one predicate — SettingsDraftDiff’s attribution, the same seam the save pill’s count and the unsaved-changes popover read. The pill is the draft’s only narrator, so what a Save writes must never exceed what the pill counted. Editing a mode and editing it back leaves the leaf equal and therefore counts as un-edited, exactly as that popover row disappears.

Two consequences are the model rather than bugs, and are named here so they read that way:

  • After an unrelated Save, screen and file may disagree about a space you did not touch. That is what “temporary” means. The quick menu’s per-entry “not saved to profile” subtitle is the one narration of it — and with the Settings drift surface retired it is the app’s only statement that a switch is temporary, which is why it stays.
  • Editing a mode back to its saved value neither saves nor ends a standing temporary layout. Ending one is the quick menu’s job: switch back, or Keep.

Settings stopped surfacing drift at all. There is no drift pill, no drift-armed Save, no drift caption under the Spaces mode picker, and Revert is a plain draft revert again. A Save enabled because of drift whose effect on that drift was identical to Revert is what this removes; the one permanent path through Settings is the ordinary draft flow — Spaces, change the mode, Save. A quick-menu Keep does move the open draft’s saved baseline onto the layout it just wrote, leaving staged edits staged: without that, the next Save would commit the pre-keep mode over the layout just kept.

[Rationale]

Space rows are bordered cards; reorder is an axis-locked handle drag, not a drag session. A system drag session’s ghost follows the pointer on both axes and cannot be constrained, and its drop choreography (snap-back flights, ghost-over-row double vision) kept reading as broken. The reorder is therefore a plain vertical DragGesture on the grab handle: the row itself lifts (shadow + slight scale) and steps slot to slot — it never leaves the column, only the pointer’s vertical position matters, and there is no ghost at all. (List.onMove was rejected too: it brings list chrome that fights the card sections and shows no better affordance.) Each row is a bordered card, the handle flips the cursor to an open hand on hover, and the name is a visible rounded-border field of fixed width — renaming is discoverable without clicking first, and the fields align in a column.

Space icons are recognition sugar; the name stays primary. Optional per-space icon (space.icon) shown where scanning many small items pays off — space rows, monitor chips, per-space shortcut labels — never as the only signifier. (#68 §6.5)

Saved profiles lead; Presets demote once one exists. On the Profiles page, the built-in presets top the page only while no profile is saved yet — they’re a bootstrap tool, and leading with an empty saved-profiles list would leave first launch barren. From the first saved profile on, the order flips: the user’s own content takes the top, and the presets close the page. The priority signal is the order swap itself, so neither state hides a card from the other. What may fold is content the live machine cannot act on — a preset for screens that are not plugged in is a reference, not an offer — or a card most users never open, and a fold of either kind still draws open by default when its contents are the answer to the question its title asks. The zero-profile state additionally gets a soft spotlight, never a gate (QA 2026-07-19): a “Start here” lead-in, ONE accent-prominent Apply — the appliable count’s Standard preset, since prominence on every appliable preset put three accent buttons in one field — an accent dot on the Profiles Home card’s tile, and a pre-filled first-save name. A hard first-run gate was considered and rejected — System Settings never gates a pane, the zero-profile state recurs whenever the last profile is deleted, and KiwiDesk tiles fine with no profile, so wandering must stay legal. All of it is state-driven on “no saved profiles” (no persisted seen-flag) and vanishes with the first profile.

A binding row reads “Desktop n”, and binding is dropdown-only. The naming is the product-wide rule — macOS has Desktops, KiwiDesk has Spaces — and this page is where it first shipped, because a row per Mission Control Desktop sitting beside a list of KiwiDesk’s own spaces is where the two systems blurred hardest. The interaction is settled separately: the earlier draggable profile chips duplicated the dropdown while adding a chip palette row and drop-target styling — a second interaction model with zero extra capability. (#7)

[Rationale]

A curated, keyword-tagged icon catalog — because macOS has no API to list SF Symbols. The system ships the glyphs but can’t enumerate them at runtime, so every symbol picker ships its own list. Ours is curated with search tags (“mail” finds envelope), which searches better than a raw dump of ~6,000 names ever could. The full catalog stays reachable: any valid SF Symbol name typed into the search appears as a result, and any single character (incl. emoji) works via “Use as text”. One IconPicker serves layer icons and space icons. (#68 §6.4)

Browsing is tabbed (Emoji first); search is global. The picker’s popover splits Emoji and Symbols into segmented tabs — emoji lead because space icons are the picker’s most frequent use — but a typed query searches both vocabularies at once (the tabs stand back, like Character Viewer). The button shows a glyph-sized smiley when no icon is set, never a “Choose…” label: the text made unset pickers wider than set ones, so rows wouldn’t line up. Clearing is a control, not a choice: the remove button sits beside the tabs (disabled when nothing is set) instead of posing as a grid cell under Recents.

[Rationale]

A shortcut is modifiers plus exactly one key. Carbon’s RegisterEventHotKey (one key code + modifier mask) is the mechanism, chosen because it needs no Input Monitoring permission. Multi-key chords (⌘J+K) are therefore not recordable — the first non-modifier key locks the combo (#212) — and a hand-written cmd+j+k is inert and flagged ⚠ unrecognized.

An absent capability is not a greyed one, and an absent Desktop is. Two states look alike from a distance and get opposite treatments, so the line is drawn here rather than re-argued at each surface.

Where macOS does not expose the window-management bridge, the three Desktop rows are not drawn at all. Grey-don’t-hide exists for a control that would work — switch that setting on and I act — and for that promise to be honest there has to be a switch. Here there is none: no setting, no mode and no future release of KiwiDesk reaches the capability, because it is the OS’s to expose. A dimmed “Go to Desktop 3” on such a Mac invites an action with no path at all, and the sentence explaining it could only say your Mac cannot do this, which is a fact about the machine rather than a state of the control. Absent is right; faked is not, and greyed is faked.

Where the bridge IS present and a bound Desktop’s screen is merely unplugged, the row dims and stays. That is the ordinary case the rule was written for: plug the screen back in and it works. It stays editable, too — recording a key for a Desktop that is away is exactly what someone who docks and undocks is doing — so it is dimmed rather than disabled.

And it dims in place rather than moving to the Inactive shortcuts card, which is where the same question was asked for Spaces and answered the other way. Two reasons. An inactive Space shortcut still fires: pressing it recreates the Space, which is what that card’s caption promises, and the promise is false about a Desktop, since only Mission Control can make one. And a Space leaves the list because the user changed their config — deliberate, rare, lasting — where a Desktop leaves because a laptop was undocked, which is routine and temporary; reshuffling rows to the bottom of the page on every undock reports a normal state as debris.

Config presence expands the Simple surface. The simple mode is not a smaller product, it is a smaller offer. Anything already in the user’s config — a shortcut layer, an imported Lua binding, a per-profile override — shows in both modes and makes the simple one richer; what Simple withholds is only the invitation to create the first one, and that invitation retires itself the moment it is accepted.

The failure this prevents is the one worth naming, because the Shortcuts area shipped it: a user who created a layer, then found the Layers card gone because they were in the simple mode. Hiding a control the user has never used is curation. Hiding something they made is losing their work as far as they can tell, and it teaches them that the mode switch is dangerous — which costs the mode its whole purpose. Nothing a user has configured is ever hidden by a mode.

It follows that “which mode is this row in” is the wrong question for such a row; the right one is “does the thing exist yet”, which is a runtime gate rather than a depth. That is what SettingTier.immediate is for, and why an .immediate row without a gate is meaningless.

And the same tier answers the question one step earlier: a row for a concept the app has not introduced is an OFFER, not a setting (#1125). The Desktop shortcut families are the worked case. Nothing seeds them, a Desktop is macOS’s arrangement rather than KiwiDesk’s, and they scale per Desktop — so a four-Desktop Mac drew twelve rows about a thing a first-run user has never met, in the area whose own ordering already says KiwiDesk’s Spaces lead and a Desktop row is the escape into macOS’s. They sit behind a disclosure in each card, which stays mounted and simply OPENS on arrival once one is bound — in both modes, which is the paragraph above applied to a capability rather than to a layer.

The drawer stays rather than giving way to a bare list, and that is not a detail: the flip would fire at the instant the user records their first Desktop combo, inside the open drawer, with the recorder focused — so the control they committed with would be torn down by their own commit, and on a keyboard-navigation Mac focus would fall to the top of the window. The reverse is worse still, clearing the last binding shutting a door on rows being edited. A permanent drawer also gives the title, the search anchor and a future ? one home instead of two.

Three things this is NOT, each considered and refused. It is not a mode gate: mode depth is per area and Shortcuts is a Simple area, so the depth does not exist to spend. It is not gated on how many Desktops the Mac has — that gates on the machine where the honest condition is the user’s own intent, and a three-Desktop user who does not want KiwiDesk driving them is the common case rather than the exception. And it is not a withhold with no door: the rows carry dynamic per-Desktop labels, so no search index can name them, and hiding them outright would leave a capability nothing on the screen and nothing in search could reach. The disclosure IS the door, which is why it is a catalog declaration — that makes the offer itself a search result even though its rows can never be. Which also means the door must not be offered where it leads nowhere: on a Mac whose window-management bridge is absent the families draw nothing, so the two declarations are refused by the search index on that same capability, the way a bridge-gated census row already is.

The second instance (#1440, owner ruling 2026-09-14) is the Track shortcut families, one drawer below the Desktop one in Move windows, and it settles two things the first did not have to. The offer’s condition is “the layout is in play” — a Track space in the config this window edits, or a Track verb bound in any layer — because the concept the user has met is the LAYOUT rather than a binding, and the starter setup seeds a Track space on a wide screen, so that user must meet the drawer open without ever having chosen the word. And it ranks BELOW Desktop: Desktop rows apply to everyone with macOS Spaces, Track rows to one layout, so the group reads from the widest audience down. (The alternative — Track above Desktop, since Track is KiwiDesk’s own and a Desktop row is the escape into macOS’s — was argued by design review and is defensible; the owner ruled audience size, and the reason is stated so the order is a decision rather than an accident.)

Overrides always resolve; the Simple/Power User mode never changes what runs. There is no stored flag deciding whether a profile’s shortcut overrides apply — an early draft of the redesign specified one, with a default and an upgrade migration, and it was cut before any of it was built. The reason it must stay cut: a preference that changes which shortcuts fire is not a preference, it is a second config the user cannot see, and the first time a shortcut goes dead because of a display setting the app has lied. So the resolver takes a base list and a sparse override and nothing else, and nothing in the Settings window is read-only because of the mode a user picked.

What the mode may decide is only whether creating an override is offered — and that offer retires itself. A used capability unlocks its whole list. The moment a profile carries one shortcut override, the override column is live on every row of that list, because a user who has overridden one key is a user who overrides keys and making them re-earn the affordance per row is busywork dressed as simplicity. The scope is the list, not the app: overriding a shortcut must not turn a deeper surface on in App Rules or anywhere else, or the mode becomes something users lose by accident and stop trusting. ShortcutsCapabilityUnlockTests holds both halves plus the mode-independence. All three already hold by construction, and the suite exists because the cheapest way to build mode mechanics later is to gate the override column on a mode — which would break every one of them while breaking no test. The resolver clause is guarded by the signature rather than by that suite: KeyLayerOverride.resolved(onto:) takes a base list and nothing else, so the obligation is simply never to add a mode parameter to it.

A named alternate keybinding set is a LAYER, never a mode. “Mode” was already carrying two unrelated meanings — a space’s layout (monocle, grid, …) and the Settings window’s Simple/Power-User depth — and a third sense made every sentence about any of them ambiguous: “switch mode” could mean three things, and “only the active mode’s shortcuts fire” had to name which kind of mode it meant to be read at all. Layer also describes the thing better, since what it does is stack a second meaning over the same physical keys. The rename runs the whole width of the product — the Lua verbs (define_layer / switch_layer), KeyLayer and its sparse override in Core, the "layers" key in gui.json and in a profile, the GUI, and the strings — because a vocabulary split across two names is the ambiguity it was meant to remove. Pre-release, single user: no aliases and no migration, so an old "modes" key reads as absent rather than as layers, and re-saving is the migration.

Switch-layer shortcuts sit right under the layer strip. The rows that switch layers render directly beneath the strip that defines the layers, ahead of the action groups — the definition and its bindings read as one unit. The strip’s caption also states that “default” is the standard layer and always the active one after an app start. Renaming a layer shares Delete’s gate (base layers are protected in profile-override editing, #55) and rewrites the switch-layer rows of the config being edited through the catalog’s single authority, so writer and import classifier keep matching byte-for-byte (#4). Scope: a stored profile whose sparse override targets the old name keeps it and resurfaces it as a standalone layer — the same accepted pre-release gap Delete has (the edit is a draft until Save, so stored files can’t be chased at click time). Saved profiles get the same affordance: a pencil beside the profile name renames immediately — file, adopted name, and Desktop bindings follow, like Delete and make default.

Shortcut layers are the layering mechanism: a layer switch gives a whole second set of single-key bindings, ergonomically better than finger-twister chords.

The active layer is shown, and showing it is not a preference (#1169, owner ruling 2026-09-14). A press on the wrong layer does something surprising, so both surfaces that can say which layer is live say it: the menu bar’s status item swaps to the layer’s icon (#68 §6.4), and the Space Bar leads its run with the layer’s glyph — one item ahead of the Spaces, drawn and announced like any other, that is no click, drag or drop target and never the active slot. default takes no icon (Lua reference ▸ Layer Icons) and is the bar’s resting shape, so the item exists only while another layer is active: the bar grows by one item on the switch and shrinks back on the return, with the same 2 pt full-depth rule between it and the first Space that stands between the last Space and the front-app segment. There is no “show layer in Space Bar” switch: with it off, the bar’s resting shape would read as default while another layer is live — the truthfulness argument that keeps the menu bar always-on — and a setting owes a census row, a Settings row, a locale round and, on any later default flip, a migration. A layer with no icon draws the same two-letter monogram a Space with a non-numeric name does, so a layer never disappears from the bar for lack of one. Both surfaces read the one layer_change bus event (#1168): the bar takes no hook on the manager, which keeps one seam.

The recorder snaps in on key-down. (#212, replacing the #68 lock-on-full-release machine.) Modifiers can be pressed and released freely — the preview mirrors what is held — and the first non-modifier keyDown locks the combo instantly: that key plus the modifiers held at that moment, the way the native System Settings recorder reads. Correction is re-recording (one click). A release-model recorder that formed chords on release was tried and dropped — buggier in practice than the one-click re-record it bought. Bare Escape cancels (Escape with modifiers records — ⌃Escape is a valid hotkey); click-away and app deactivation cancel unchanged. A swallowed key-down owns its matching key-up even if the field disappears or another recorder takes over; a short timeout bounds that handoff monitor. The post-commit duplicate hard-block below is now the sole conflict surface.

Duplicates hard-block; system shortcuts soft-warn. Recording a combo another KiwiDesk row already holds is rejected inline with Steal (rebind here) and Go to (jump to the holder) — silent duplicates were the #34 bug class. A collision compares parsed physical shortcuts, so aliases such as alt+j and option+j cannot evade the block. A macOS system-shortcut collision instead commits with a persistent ⚠ — shadowing one can be intentional, and the row is the reference surface, warning on every registered chord whether macOS currently answers it or not; the aggregate surfaces consult the live enabled state to decide what COUNTS (the ⌃⌥⌘8 ruling below, #1105), and since #1126 the row reads the same bit to pick its TIER: a symbolic hotkey macOS has on is a dead row — outlined chord, worded caption, “won’t work” — because the press goes to macOS and the row never fires (measured 2026-09-03); one macOS has off keeps the quiet ⚠ and says it is off; a chord every app’s menus carry (⌘W, ⌘Q, ⌘H, ⌘M) is the reverse — KiwiDesk wins it, measured on ⌘W and ⌘P, so the row says every app loses that item; and the two system-level chords outside the table (⌘Tab, ⌥⌘Esc) keep the collision wording, their precedence being unmeasured. The treatment is deliberately smaller than a tinted row: outline plus caption already carry the fact in two channels, a red ground would be a new register needing its own inks, and a filled badge has no ink that clears 4.5:1 in both modes (ui-designer, 2026-09-03). Conflict surfaces (the banner and the “Assigned to…” row) re-derive from live bindings on every render, so fixing the conflict anywhere — clearing either row, deleting the holder — retires them without a dismiss. (#33/#34/#35, #68 §3.6.2)

One recorder at a time. Starting a recording snaps any other recording field back instantly. (#33)

An armed recorder suspends KiwiDesk’s hotkeys. (#213.) A combo you are about to bind is often already bound to a window action, so pressing it to test it would fire that action mid-capture. While any recorder is open, the manager unregisters every KiwiDesk Carbon hotkey and re-registers the current layer when it closes — the suspend/resume round-trip the exact table, so a layer change made while armed is honored on resume. The RecorderCoordinator drives this on the idle↔armed edge only, so hopping between fields never bounces the registration. It never touches macOS/system shortcuts (not ours to unregister) and needs no Input Monitoring permission — it is pure Carbon (un)registration. This is the accepted first slice of the recorder-collision redesign (#213): the “Assigned to…” row also gains a colour-independent ⚠ glyph so the conflict does not read by colour alone. The larger pending-candidate model (candidate-only “Not assigned” state, Replace/Change transactions) is scoped separately in #213 pending a design round — the current Steal/Go to hard-block stays the shipped conflict UX until then.

The recorder live-applies on the live target; stored profiles stay staged. (#123 Part 1.) A recorder is an input device — “recorded but inert until Save” broke its mental model (users pressed the new combo and nothing happened). A successfully committed recording (or clear) on the live edit target re-registers the running Carbon hotkeys immediately, with no file writes. The runtime source starts from the clean Settings baseline and accumulates recorder combo mutations only: staged Lua bodies, app choices, layer edits, and other shortcut fields never hitchhike on a recording. A new row’s action is required payload for its first recording; later non-recorder edits to it stay staged. The base then resolves through the active profile’s override, matching Save + reload semantics. isDirty and the save pill keep their meaning (“the file hasn’t caught up”); Save persists base shortcuts globally in gui.json, while stored-profile editing owns sparse profile overrides.

Re-registration prepares every Lua callback before one atomic layer-table swap, then activates the preserved runtime layer once (profile/config applies still reset to default). Feedback is scoped to the exact row and layer: “Active now” only after that combo registered in the active layer; inactive-layer, profile- shadowed, compile-failed, and Carbon-denied states say so instead. Revert first re-applies persisted state; if the sidecar/profile became unreadable, an in-memory pre-edit snapshot removes ghost hotkeys. That snapshot is valid only within its loaded config/VM generation; a newer authoritative reload wins and retires the session instead of replaying stale GUI callbacks. Rollback bookkeeping clears only after one path succeeds. Editing a stored profile stays fully staged (instant apply would rewrite the RUNNING hotkeys while the banner says an inactive profile is being edited); the override banner states that its shortcuts take effect the next time the profile is active.

A catalog label’s identity and its display text are two different fields. KeybindingCatalog’s NavCommand.label (and StandardLayout.name/.summary) stay the stable, English canonical text — persisted into KeyBinding.label, matched on by KeybindingImportClassifier (keyed off lua, never display text), and used to seed a new saved profile’s name (freeName(base: layout.name)). Only a separate resolvedLabel / displayName / displaySummary — resolved through L(...) at render time, keyed by the stable field — translates. This keeps a language switch from ever rewriting persisted data or breaking import classification (issue #9 follow-up: the original literal-routing sweep covered SwiftUI view literals but missed catalog-defined strings).

Core names it; the GUI says it (#96). Core never returns a rendered sentence: it returns the structure, and the GUI localizes at its own boundary. The reason is ownership, and stating it as actor isolation is a trap worth naming, because the codebase falsifies that version — L() is @MainActor, but so is KiwiCore, which called it happily until #601. Some detection genuinely is actor-free (KeybindingConflicts, StandardProfiles) and so genuinely cannot; that is a consequence, not the rule. What binds everywhere is that copy owned by Core cannot be re-rendered when the user switches language, and an English literal there never reaches extract-keys — so it never becomes a key and no locale can translate it, however complete. That is the failure #601 found in ConfigIssue and the preset summaries, and a rule phrased around L() would not have caught either. KeybindingConflicts.conflict returns a Conflict whose target names a SystemShortcut case, and ConflictText / SettingsModel+ConflictMessages render the row tooltip and the banner from it at two different lengths. The rejected alternative was a non-@MainActor lookup path for “just these few strings”, which buys one file’s convenience by making the manager’s isolation a special case. The mirror this creates — one L(…) per enum case — is guarded by the compiler, because the switch is exhaustive: a new case cannot ship without a string. Only what the compiler cannot see needs a test (two cases resolving to the same string, SystemShortcutNamesTests).

First run seeds a starter shortcut set — base tier, only into emptiness. A fresh install used to boot with zero shortcuts (the default layer existed but was empty): a GUI-first user had no way to focus or move a window until they authored every combo. Now Core.DefaultKeybindings seeds a starter set on an escalating Control-Option scheme (#270): ⌃⌥ arrows focus / ⌃⌥⇧ arrows swap, ⌃⌥ / ⌃⌥⇧ / ⌃⌥⌘ digit per-space go / move / move-and-follow, ⌃⌥F float, ⌃⌥S sticky everywhere, ⌃⌥P sticky to this screen (#1094) — plus, since #1075, ⌥⌘ 1/2 and 4/5 for size on a base of its own — with one guard everywhere: only when no layer carries a single binding — a user- or Lua-authored binding anywhere blocks the seed, making it idempotent and never destructive.

Why Control-Option, not bare Option (#270). On macOS Option is the special-character (AltGr) modifier, so a global +key hotkey swallows text entry on every Apple keyboard layout. What it composes is layout-specific, and the examples here were the author’s own keyboard until they were measured (through UCKeyTranslate over every installed layout, 2026-08-29, macOS 26.6): ⌥L=@ and ⌥5=[ hold on German, Austrian and ABC–QWERTZ, while US, British, Canadian, Dutch, Danish, Spanish and Brazilian give ¬ and , French ¬ and {, Italian ¬ and ~. The rule survives the spread and the examples do not, which is the point: name a layout when quoting a glyph. (Japanese Kana input is the one measured exception — there returns the plain character — so a Japanese-language surface must not quote these at all.) macOS composes those characters only when the modifier is exactly or ⌥⇧; adding Control (or Command) suppresses it, so ⌃⌥ is the lightest text-safe chord (the earlier bare- set, and Amethyst’s ⌥⇧, are not). It overlaps VoiceOver’s ⌃⌥ modifier, inert unless VoiceOver is on and remappable to Caps Lock, and macOS reserves exactly one chord on it — ⌃⌥space switches the input source (com.apple.symbolichotkeys id 61, enabled, read 2026-08-29 on macOS 26.6). Nothing is seeded there, and SystemShortcuts.map carries it so a user who binds it is warned rather than left with a silently dead hotkey (#1094). ⌘⌥ was rejected because it collides with chords macOS and every app’s menus already hold (Force Quit, Dock, Hide/Minimize — the last two are app accelerators KiwiDesk would take away rather than lose, #1126) — narrowed by #1075 below, which measures the base and opens it to digits under a stated boundary. Directions bind the arrow keys, which never compose a character on any layout. The set lives in the base gui.json layers, never a profile override (profiles stay tiling-plus-sparse-behavior, #55): on first launch the seeded model is persisted so the very first boot is GUI-managed and the shortcuts actually fire.

The ladder is not uncontested either, and that is named here so a later measurement does not read it as a regression. Finder binds ⌃⌥⌘17 to View ▸ Sort Groups By (2026-08-29, macOS 26.6), which is tier 3’s digit range (move-to-space-and-follow). It is admitted on the same criterion the size digits are, below: a Finder View submenu, menu-reachable, and Finder is rarely frontmost while windows are being thrown between Spaces.

⌃⌥⌘8 is the harder one on that rung, and it is ruled rather than accidental (#1094). It is tier 3’s move-to-space-8 chord AND macOS’s Invert Colors (symbolichotkeys id 21, read 2026-08-29 on macOS 26.6). Unlike the Finder rows this one is not recoverable: the WindowServer delivers a press of an ENABLED symbolic hotkey to macOS’s own handler and KiwiDesk never hears it (measured 2026-09-03 on ⌘Space and ⌃↑; the registration itself is accepted — #1126), so for a user who turns Invert Colors on the row does nothing and says nothing. It stays bound anyway, because Invert Colors ships DISABLED — dropping the row would take a working shortcut from everyone to spare the few who enable it — and because moving it is not available, the digits being space POSITIONS rather than names. What the ruling buys is the register entry: the chord is now in SystemShortcuts.map, so the editor can say why, where before the failure was mute on both sides. SizeLayerSeedTestsknownShadows is the one copy of that exemption, and it reds if the chord ever stops being reserved.

Since #1105 the verdict stops guessing at that population split: aggregate surfaces (count, banner) read the machine’s own com.apple.symbolichotkeys enabled bit live, so a user who turns Invert Colors on sees the collision counted while everyone else stays unalarmed — a static “ships disabled” set was wrong for exactly the people the warning matters to, and silent about being wrong. The read is an ordinary preference read at the GUI boundary, and a cached reader does see an external write (measured 2026-09-01, macOS 26.6.2, foreign-domain probe); a toggle made while Settings is open is honored on the next recompute, one recompute stale at worst — accepted, since the alternative is refresh machinery for a state that changes at most once per user, ever. The two rungs carrying the most traffic take no app collision at all — ⌃⌥ and ⌃⌥⇧ came back with no menu bindings across the same sixteen apps, which is the property #270 chose this base for in the first place; the one chord macOS itself reserves on ⌃⌥ is named above. One near-miss for whoever measures next: macOS 26 puts a Window ▸ Move & Resize submenu in every app’s menu bar whose quarter-tile rows read ⌃⌥⇧+arrow, and they carry fn as well — a different chord, and not one a Carbon hotkey can be registered on.

The keypad’s ten digits ARE their number-row twins (#1074). A binding written 4 fires from either physical key, and nothing binds them apart. Keep that set closed: the tempting generalisation — “a keypad key mirrors whatever character it prints” — needs an exception the moment it is written, because keypad + has no number-row twin at all (main-block + is ⇧=), while the digit-only rule never needs one. Every other keypad key (+ − × ÷ . = enter clear) is its own bindable key.

Aliasing rather than ten more distinct keys follows the platform instead of our own model: AppKit menu key-equivalents match on the CHARACTER, so ⌘1 and +keypad-1 are one shortcut in essentially every Mac app. KiwiDesk binds by key code — chosen for international position-stability, not to tell a keypad from a number row — so the twin has to be stated somewhere, and KeypadKeys is the one place it is: hotkey registration reads it to register the second physical key, KeyCombo.keyName reads it to canonicalise a captured keypad press back to its digit. The cost is accepted rather than discovered — ten keys a power user might have wanted bound separately are gone for good, and “the keypad works like the number row” is worth more than they are.

The keypad is deliberately NOT drawn on the Settings board (#1074). KeyboardMatrix.PhysicalType distinguishes ANSI, ISO and JIS, and macOS exposes no “a keypad is attached” signal at all — so a drawn keypad would show every laptop a block of keys it does not have, which is worse than omitting it. Bindable-but- undrawn is already the board’s shape: f1f12, home, end, pageup and pagedown are all bindable and none is drawn, and drawnCodes states outright that a key the board omits is not counted as free. The board is a preview, never a census.

Two display residues follow, both accepted rather than discovered. The “taken” tally counts what the board does not draw: KeyboardCensus.takenKeyCount folds over every bound code, so binding ⌃⌥keypadplus raises the count while lighting no cap — already true of f1f12 and the navigation keys, and widened by eight codes here. And a non-digit keypad key draws the character it prints, which is the glyph its main-block twin already shows, so ⌃⌥keypaddecimal and ⌃⌥period both render ⌃⌥.. The bindings stay distinct — conflict detection compares key codes — so it is a display collision only, and it is left alone deliberately: macOS offers no keypad-distinguishing glyph, and a Num-style English prefix minted in Core would be neither localizable nor Apple-native. Keypad Clear is the one that could not be left, because it prints nothing at all: without a fixed in ComboSymbols.specials it fell through to the uppercased key NAME, rendering KEYPADCLEAR inside a compact chord (KeypadKeysTests).

Size is not a positional verb, so it takes a base of its own: ⌥⌘ (#1075). The ⌃⌥ ladder above escalates over one idea — who the verb acts on — and resize never belonged to it. Focus, swap, move and follow all place a window in the flat array; resize changes a weight or a scroll-slot domain instead. Parking it on tier 3 made mean two unrelated things at once — resize with an arrow, “and follow” with a digit — so the ladder could not be stated in one sentence. Moving it off restored that, and #1176 below then gave a second POSITIONAL sense on purpose: what it may not carry is a verb from another idea, which resize was.

The ladder is two bases plus one key, and what divides them is not taxonomy: ⌥⌘ carries the verbs you HOLD, ⌃⌥ the verbs you PRESS. “Not positional” is why resize had to leave tier 3; “you hold it” (#1056) is why it landed on ⌥⌘. Those two are different arguments, and conflating them is what left this split feeling unsettled — every later attempt to move the non-positional TOGGLES onto ⌥⌘ was reasoning from the taxonomy, which was never the load-bearing half. Size is the only held verb, so it is the only thing on ⌥⌘; the toggles (⌃⌥F, ⌃⌥S, ⌃⌥P) are pressed, so they stay on ⌃⌥ as mnemonic letters; and app chrome (⌃⌥K) is the one key that is not a window verb at all.

Stated that way the rule predicts, which is what makes it a rule rather than a label: a future glide-able verb goes to ⌥⌘, anything you tap stays on ⌃⌥.

Swap rides ⌃⌥⌘, and keeps the digits (#1176, owner ruling 2026-08-31). ⌃⌥⇧+arrows is a three-modifier claw on one hand; ⌃⌥⌘+arrows is markedly easier, and it was free of KiwiDesk’s own verbs since the size layer left. The cost is stated plainly: stops being the uniform “act on the window” qualifier across arrows AND digits, and ⌃⌥⌘ stops meaning only “and follow”. What replaces it is still one sentence — sends the window to a space, is the stronger step: swap it with a neighbour, or send it and go along — so the ladder is retuned rather than muddled. Ergonomics beat grammar purity here because the grammar survives the change in a form a user can still say out loud.

What the collision measure actually says, and the artefact it nearly shipped. SystemShortcuts.map carries no ⌃⌥⌘+arrow entry, and it is necessary and not sufficient (#1098), so the app menus were enumerated over the AX menu-bar tree of every running application (the probe is in plan/). Measured 2026-09-04, macOS 26.6, ten running apps: neither ⌃⌥⌘+arrows nor the ⌃⌥⇧+arrows it retires is claimed by any app’s SHIPPED menu defaults. So the retune neither buys nor costs an app-menu collision, and rests on the ergonomics alone.

Two blind spots, both paid for by getting this wrong first. The sweep reads EFFECTIVE accelerators, so it cannot tell a shipped default from the reader’s own override: the one ⌃⌥⌘+arrow hit it returned was a developer’s own terminal config, not Ghostty’s default (super+alt+arrow), which the app’s own +list-keybinds settles in a second. Check a hit against the app’s defaults before recording it as one.

And the first pass reported every one of the ten apps claiming ⌃⌥⇧+arrows — an artefact worth recording, because the trap is invisible and this file had already warned about it one section up. AXMenuItemCmdModifiers encodes fn in a bit the AX headers do not document (16), so a reader checking only shift/option/control/command sees macOS’s fn⌃⌥⇧ quarter-tile rows as a plain ⌃⌥⇧ collision, in every app, because macOS injects that submenu everywhere. Decode that bit, or the sweep invents a collision with the system’s own window tiling. The known-truth check that settles it: Window ▸ Move & Resize ▸ Left reads mods=28, which is fn + control + “no command” and matches Apple’s own documented fn⌃←.

has one meaning, and a toggle does not qualify (#1094). ⌃⌥⇧S spent on “a broader scope” while the ladder three rows up spends it on “act on the window” — the one chord in the seed a user who had correctly learned the ladder would read wrong. It is gone: global sticky took ⌃⌥S (S still leads the label a GUI-first user is shown, “Toggle sticky everywhere”), and the screen-scoped one took ⌃⌥P, named for the pin.fill mark StickyStyle draws rather than for a label — because a label-derived mnemonic only works in the language the label was written in, while a mark is a picture.

D was the obvious letter and it was refused, recorded here because a later author will reach for it again. Three counts: Desktop is a first-class noun in this app with its own catalog rows, so ⌃⌥D reads as a Desktop verb before it reads as a sticky one; the letter would anchor to display, a word config-vocabulary.md retires for a screen, so #865 would strand it; and the mitigation offered for the first count — “Desktops and Spaces are always digit-bound, so no lettered chord means Desktop” — is a state claim nothing enforces (#614), since goToDesktop and moveToDesktop ship as offerable unbound rows a user may bind to any letter they like. So now means exactly one thing everywhere, and so does .

The ergonomics ran backwards too. #1056 made resize the one verb a user HOLDS, and it sat on the heaviest chord in the scheme — three modifiers with and at opposite ends of the left cluster. ⌥⌘ is a single thumb roll, because the two keys are adjacent.

This amends #270’s rejection of ⌘⌥ rather than contradicting it. That ruling turned the base down for colliding with always-on system shortcuts, and named Force Quit, Dock and Hide/Minimize. The amendment is narrower than “those were wrong”: they are real, and the boundary below keeps clear of them. What #270 did not do is enumerate the base, so the collisions were known by reputation rather than measured.

They are measured now, and the first draft of this layer was wrong because they were not. Reading com.apple.symbolichotkeys on macOS 26.6 (2026-08-28), the chords whose modifiers are exactly ⌥⌘ are: 8 (Zoom on/off, id 15), = (Zoom in, 17), - (Zoom out, 19), \ (23), D (Dock hiding, 52) and space (Finder search, 65). So ⌥⌘8 is a digit and it is macOS’s, which an earlier 4/5 + 7/8 draft of this very layer had taken for Grow height — dead for every user with Zoom’s keyboard shortcuts on, since macOS answers an enabled symbolic hotkey first and the row is never told (#1126; the registration is accepted, which is why nothing said so). Zoom’s three are gated on Accessibility ▸ Zoom ▸ “Use keyboard shortcuts to zoom” and ship off, which is exactly why a reputation-based enumeration missed them. SystemShortcuts.map now carries the ⌥⌘ family, so the app warns instead of the prose promising; SizeLayerSeedTests holds every seeded row against it.

The boundary, stated as what it is: ⌥⌘ is free only where the register says so. Never arrows — ⌥⌘←/ is next/previous tab in Chrome, Safari and Terminal (observed 2026-08-28) — and a global Carbon hotkey pre-empts the frontmost app, so binding them would take tab switching away system-wide.

That pre-emption is measured, not inferred (owner, 2026-08-29). A KiwiDesk resize verb was bound to ⌘P — VS Code’s Go to File — and pressed with VS Code frontmost: KiwiDesk fired and Quick Open did not. So the order is macOS’s own chords, then KiwiDesk, then app menus: a live symbolic hotkey is answered by macOS before KiwiDesk hears the press (measured 2026-09-03, ⌘Space and ⌃↑ — RegisterEventHotKey accepts the chord regardless, #1126), and KiwiDesk WINS against an app’s own. It matters because every collision in this section is a real cost to the user rather than a theoretical one — the app loses the chord, silently, and the criterion below exists to rank which of those losses are tolerable. Never esc, space, D, or the Zoom trio. A new default on this base is checked against SystemShortcuts.map, not against this paragraph.

And the criterion is applied to digits too, rather than assumed past them. Arrows are excluded above on an app-level collision, so it would be dishonest to admit digits on the system list alone. The first draft of this paragraph admitted them from Xcode’s reputation rather than from a measurement. Enumerating the menu shortcuts of sixteen installed apps through the accessibility API (2026-08-29, macOS 26.6) found ⌥⌘+digit bound by Finder (1 2 5 6 7, View ▸ Clean Up By), Preview (0 1 2 3 4 5 6 9), Safari (14, Bookmarks ▸ Favorites — a range that scales with the user’s own bookmark count) and PowerPoint (1 2 3, Masters). Finder and Preview ship on every Mac, so this is not a collision some installs have and others do not: wherever the size layer seeds, it shadows something.

The digits stay; what changed is the criterion. Nine of the ten digits were claimed by an app in that enumeration, and the tenth — 8 — is macOS’s own Zoom toggle above, so there was no free pair to move to. The rule that outlives the snapshot is the shape of the question: never which digits are free, but which collisions are worth accepting. What separates the admitted set from the refused arrows is not frequency (somebody’s Safari Favorite 1 is pressed all day) but recoverability and diagnosability: Clean Up By, Preview’s View menu and Safari’s Favorites each have a visible menu path, so what is lost is an accelerator rather than a capability, and the failure presents as a menu item that did not respond — something a user can reason about. ⌥⌘←/ tab switching has no menu affordance anyone thinks to check, so its failure presents as “the browser is broken” and never reaches us. Apply that test to the next chord question. The enumeration above is dated evidence for it, never the rule itself: restating a snapshot of other people’s software as a rule is exactly how the Xcode sentence rotted.

The geometry was load-bearing independently of any of this. The measurement chooses, and it chooses AGAINST the shipped set: {4,5,7,8} takes five app bindings (4 → Preview and Safari, 5 → Finder and Preview, 7 → Finder, 8 → none) against {1,2,4,5}’s twelve, since 1 and 2 are each claimed by all four apps. What refuses the rejected draft is not the app count but the SYSTEM chord: ⌥⌘8 is macOS’s Zoom toggle, KiwiDesk loses that one outright, and a seeded row that silently never fires is worse than one that costs an app a menu accelerator. One thing the glide changes in the arithmetic: since #1082 resize is HELD, the chord is pressed once per gesture rather than tapped repeatedly — which strengthens the ⌥⌘ thumb roll and weakens the frequency case against its collisions, a held verb being used in bursts rather than scattered through the day.

Why digits and not some other pair. An arrow carries two readings on a tiled window — “which axis and sign” and “which way the edge moves” — and which edge is free depends on where the window sits in the array, so the same arrow grows a right-column window and shrinks a left-column one. Nothing about relabelling fixes that; the arrow shape creates it. Digits carry no directional claim, and they are the only key family that holds its physical position on every layout. -/= was considered and rejected twice over: on a German ISO board those positions print ß and ´ while the actual - and + legends live two keys apart, so the +/ mnemonic is a US-layout artifact — and the measurement above shows macOS owns that exact pair for Zoom anyway.

Within a pair the higher digit grows. 1/2 and 4/5 form a 2×2 block on a numeric keypad, 4/5 directly above 1/2, so the pair that sits higher drives the dimension that grows upward — the only place a keyboard encodes a second axis without arrows, and the reason this binds digits at all. #1074 landed the aliasing that makes those keypad keys reach these rows: a keypad digit IS its number-row twin, so both physical keys fire them. The pairs are also separated by 3 on the number row, so a mistimed reach for one axis cannot land on the other (owner, 2026-08-28). Any pair spanning 5/6 was rejected: touch typing splits the row there.

What this costs, since the charter’s job is to stop the re-litigation. A user now learns a base and a ladder rather than one ladder, and the digit→axis map has no glyph relation to width or height — an arrow at least carried its own meaning, where 1/2 versus 4/5 must be memorised or read off the keypad block. The trade accepted: that cost is paid once, while the arrow’s ambiguity was paid on every press from a column where the free edge ran the other way, and ’s double meaning was paid by every reader of the scheme.

No migration, deliberately. The seed guard above fires only when no layer carries a single binding, so every existing config blocks it and digitTopUp is strictly additive. Nobody is moved: an existing install keeps ⌃⌥⌘+arrows for as long as it lives, and ShortcutsReferenceBuilder renders the live layer rather than the defaults, so its Shortcuts panel stays accurate. The cost, accepted rather than discovered: there is no restore-to-defaults affordance in the Keybindings editor, so an existing user who WANTS the new map hand-edits four rows, and prose that names a chord has to say which population it means — which is why the resize workaround in Accepted limitations points at the ⌃⌥K panel instead of naming one (SizeLayerSeedTests).

The seed fires whenever init.lua declares no managed settings — not only when init.lua is absent (#354). The original gate (“no init.lua yet”) silently punished a user whose init.lua carries only harmless custom Lua — the documented sketchybar event-hook bridge — booting them to a bare single space with no profile. The seed now gates on ManagedConfig.declaresManagedSettings: a superset of hasForeignCode that also catches the set_* verbs, including the namespaced layout setters (bsp.set_ratio_h, stack.set_master_ratio, …) that editor-fallback ignores. Those verbs are derived from APIReference.namespaces (the one registry) so the check can’t drift as sub-APIs grow. Result: a hooks-only or comment-only init.lua boots GUI-managed with the defaults and keeps firing its hooks; an init.lua that declares tiling settings of its own stays Lua-owned (no seed — seeding would let the GUI defaults overwrite its Lua tiling) and is offered the Adopt path instead. With a settings-free init.lua the seed appears in the editable model and persists on the first Save. Per-space rows number the digits by display position but bind each to its space by name (⌃⌥3 → the third space’s name at seed time; a later rename rewrites the binding to follow it, so it survives). The first run pads the discovered list to the starter setup (see below) so the digit shortcuts seed even though a fresh macOS reports only the active Space (#270). Digits scale to the seeded count, whatever the setup’s budget makes it: up to ⌃⌥1⌃⌥9 plus ⌃⌥0 for a tenth space (0 is the top-row key after 9; there is no eleventh, so spaces past the tenth ship unbound — see Accepted limitations). The seeded Lua and labels mirror KeybindingCatalog byte-for-byte (guarded by DefaultSeedCatalogParityTests) so the rows stay presets, not Custom (#4). (#91/#466)

A fresh install seeds a real starter setup, not nine flat spaces (#466). The old first run padded to nine numbered bsp spaces purely so ⌃⌥1⌃⌥9 had somewhere to go (#270). But a shortcut never needs a pre-created space — focus_space already ensureSpaces on first press — so the nine existed only to back the digits, and every new user stared at nine identical bsp spaces.

What the seed CONTAINS is now ruled above, under “the starter setup is chosen from the screens” (#678 Phase 4 pass 11), which supersedes #466’s five-per-display ladder. What survives from #466 is everything about how it is delivered, and those parts are load-bearing whatever the layouts are:

Because the per-space modes, monitor pins, and tuning are profile-scoped while gui.json carries only globals, the setup is materialized as a real, adopted Starter profile at first run (seedFirstRunStarterProfile, after the event loop reconciles displays) — the same durable store any saved profile uses, so a reload re-applies it and the user owns and edits it like any other. The identical setup is also offered as the Starter preset (StandardProfiles), sharing one pure generator (StarterSetup) with the seed so the two never drift. It is deliberately not the silent isStandard fallback: landing in a hardware-derived setup on a monitor change would be a poor default, so the workflow Standards keep that job. First-run-only, and gated on the same “no authored binding disarms the seed” guard, so it never touches a configured setup. (#466, supersedes the #270 nine-pad; superseded in its own turn on WHICH layouts by #678 Phase 4 pass 11)

The starter setup IS the unmatched-change fallback — but only while it’s the active baseline (#485). [Rationale] #466 keeps it out of the silent isStandard fallback so nobody else lands in a setup derived from someone else’s hardware. But the beginner who started on it hit the mirror-image bug: the seeded Starter profile only covers its first-run display count, so plugging a second monitor matched no stored set, fell to .none, and composed a workflow Standard — handing the newcomer a different number of spaces and no ⌃⌥N past the seeded count. The fix scopes the override tightly: handleMonitorChange’s .none branch recomposes the starter setup for the live screens only when the user is on the Starter baseline (isOnStarterBaseline — the adopted seed profile, flagged Profile.isStarterSetup so the identity survives a rename or an edited mode, or a transient Starter Standard from an earlier change, sticky via currentStandard). Every other baseline still gets the workflow Standard, so #466’s “no silent demo layout” promise holds for everyone who didn’t choose it. The flag rides re-saves and edits but not a save-as-new — an explicitly named copy is the user’s own profile and resolves normally (copyProfile clears it beside isDefault, the two identity flags a copy must neutralize). A transient Starter Standard carries the flag onto the first profile the user saves of it, via buildProfile reading currentStandard, so a save doesn’t drop them off the baseline either. Both recompose sites are covered: handleMonitorChange’s .none branch and reapplyActiveProfileState (a config reload) both route through composeMonitorChangeFallback, and apply(composed:) now adopts its own composed.assignment (adoptComposedPlacement) rather than discarding it — equivalent for a workflow Standard, correct for the starter setup’s per-screen blocks, which are no longer even the same size as each other. The digit-shortcut half is the additive twin: topUpDigitShortcuts binds only the ⌃⌥N a growth left unbound (GUI-managed, never overwriting a custom chord, capped at ten), so the shortcuts follow the spaces. Do not “simplify” either recompose site back to a bare StandardProfiles.standard, nor make apply(composed:) discard its assignment again — each reintroduces #485. (#485)

Orphaned space shortcuts are surfaced, never pruned. A binding that targets a space by name outlives the space’s presence in the current profile: it stays Carbon-registered (pressing it recreates the space via ensureSpace) and keeps its combo (the recorder preflight checks every stored row, not just visible ones). Before #92 it was also invisible — the per-space catalog rows render only live spaces, and the Advanced drawer shows only .custom — so the user was hard-blocked by a holder they could not see, and the rejection’s Go to scrolled to a row that did not exist. Now a dimmed Inactive shortcuts section renders one ordinary NavRow per orphaned binding (detected via SpaceLuaArg.targetSpace, the strict inverse of the catalog’s authoring, against the live-derived space list, #77), so rebind / clear / Go to all work. Pruning on save was explicitly rejected: a binding orphaned under a 4-space profile is valid again under the 8-space one — silently deleting it would lose config across a routine monitor swap. The rows stay live at runtime by design; only their visibility was broken. (#92)

The shortcuts panel scrolls, and says so — in words, in the footer. The panel is a glance surface but not a small one: the seeded keymap grows three rows per Space, so a stock setup runs to more rows than fit under the height ceiling ShortcutsPanelController.resize derives from the screen. It may therefore exceed the viewport, and the fold is cued by a line in the footer drawn only when resize’s own verdict says the content clipped — overflows(fitting:ceiling:), one home, handed to the view rather than re-derived by it, so the cue cannot disagree with the clamp that causes it.

The platform’s own indicator was tried first and does not work. .scrollIndicators(.visible) shipped for one commit and was measured on device (macOS 26.6.2, 2026-09-07, AppleShowScrollBars unset — Automatically — 46 bound shortcuts, ~45 rows against ~20 that fit): the overlay scroller still appeared only during a gesture. Apple documents the visibility as depending on “user preference behaviors in macOS”, so the modifier is expressed intent the platform may decline, and here it declines. It is unobservable in all three preference states — under Automatically with a trackpad it loses to the auto-hide, with a mouse macOS already draws a legacy scroller, and under Always the scroller is permanent anyway — so it was removed rather than left as a hedge.

Why words, and why in the footer: a partial row is not a fold cue when a terminator sits under it. The fold cuts a row in half, which looks like a hint that the list continues — and is not, because immediately below it sit a full-width Divider() and a footer carrying a hint and a button. A hard rule plus chrome is the strongest “this is the bottom” signal a panel has, and it cancels the cut above it. That is why no further geometry at the fold can win: it would have to argue against a terminator two points below. The answer is to put the words ON the terminator — make the thing that says finished be the thing that says there is more.

The line is imperative rather than positional (“Scroll for more shortcuts”), so it needs no scroll offset to stay honest; tracking the offset would mean onScrollGeometryChange, which is macOS 15 against this project’s macOS 14 floor, so it would ship the static cue as its fallback anyway. It names its object because Scrolling is one of KiwiDesk’s layout modes and its verbs can be on screen in the same panel.

Two rejections worth keeping. A bottom fade cannot be judged here at all: the panel’s ground is .regularMaterial over a clear window, so a gradient fades the last rows toward the user’s wallpaper — there is no colour to fade to, its lightness is unmeasurable, and the colour-vision clauses therefore have no answer. It also removes information on a surface whose only job is to be read.

And scrollerStyle = .legacy is refused on ownership, not on preference. It is a per-NSScrollView property, it writes no user default, and Automatically is a delegation rather than a vote against legacy scrollers — so a preference argument would not carry. What decides it is that KiwiDesk does not own this scroll view — it is SwiftUI’s private backing, reachable only by walking the hosting hierarchy for a class SwiftUI does not promise to keep. That is the same species of bet .scrollIndicators(.visible) just lost, and it fails silently: the traversal returns nil, no cue is drawn, and nothing says so — which means the words are owed as its fallback, and once the words exist the traversal buys nothing. flashScrollers() dies the same way, plus transience. The door is open by ownership: if this panel is ever rebuilt as an NSViewRepresentable over a scroll view it constructs, .legacy becomes legitimate and is re-argued then.

Making the content fit (#1294) lowers how often the fold appears and cannot retire the cue — 45 rows do not fit under a 720 pt ceiling at any column count, and an install with many app or raw-Lua rows overflows any fixed size. The cue is permanent, not a stopgap. The asymmetry that issue would exploit — the panel’s height derives from the screen while its width is a literal 760 — is its own ruling if taken.

The shortcuts panel takes UNTINTED Liquid Glass, and the untinted half is a ruling rather than a limit. (#1295/#1293, 2026-09-07.) A large translucent panel summoned over the desktop is the exact shape the platform now renders in glass, and the panel’s own fallback already existed — the macOS 26 branch degrades to today’s .regularMaterial, which is today’s design rather than a glass-adjacent approximation, because a surface that degrades to something nearly glass claims a finish the machine cannot draw.

Three reasons it is declined, hardest first:

  1. A tint DARKENS, and this surface was ruled to need the light field. Measured 2026-09-07 on an identical ground, a neutral white tint took .clear from luminance 144 to 141 at 15 % to 134 at 30 %, with chroma rising: SwiftUI’s Glass.tint(_:) dims by the colour’s alpha rather than carrying it, exactly as AppKit’s tintColor does ▸ Liquid Glass is an orthogonal finish toggle. The two toolkits agree, so this is a platform fact rather than an inherited guess. And the panel’s ink is .primary / .secondary — DARK in light appearance — so every step of that dimming comes out of the contrast of a wall of small type. SettingsTheme.accent is no exception: at greyscale ~166 against the plate’s ~203 it is a darkening wash before the dimming is counted.
  2. The bars’ argument does not transfer. A bar’s ink is fixed palette hex with no vibrancy path, so its fill is a legibility floor. This panel’s prose is vibrant throughout — seven foregroundStyle sites, all .secondary — and its two fixed hexes are not prose: the chip’s accent fill and the seal’s ink, which sits on the button’s own fill rather than on bare glass. .regular strengthens this rather than weakening it: a calmer composite is a better backdrop for the vibrancy resolve to work against.
  3. The one accent mark here is the layer chip, and a green ground erases it. Weakest of the three and deliberately last: the entry below already accepts that the chip’s colour carries no information. It survives only because kiwi-on-kiwi at two alphas makes a fragile mark systematically worse rather than situationally so, and that failure is colour-vision-blind — it is one colour on itself. (The Edit button is not a second accent mark: the bordered seal neutralises it to ink.)

“Kiwi, like the bars” is aiming at something that does not exist. AppBarStyle.fillColor defaults to #14201CB3 — RGB (20, 32, 28) at 0.70, capped to GlassTint.maxAlpha — which is a near-black with a whisper of green, a dark HUD backdrop, not the accent. Copying it here would not make the panel kiwi; it would make it dark, which forces a fixed light ink, which forfeits the vibrancy of reason 2 — the bars’ own chain, run backwards. It is also a user setting rather than house chrome, so giving this panel one moves a decision onto the user instead of removing it.

And the two mechanisms are not two strengths of one idea. Glass.tint(_:) dims (above); only a GlassTint-style coloured backdrop actually carries hue, because the glass refracts a sibling plane behind it. That is precisely why no tint belongs here: a backdrop opaque enough to read as a colour is opaque enough to stop the wallpaper coming through, and the wallpaper coming through is the whole reason to adopt glass. Recorded so a future attempt does not reopen this by trying “the other one”.

The panel takes .regular and the bars take .clear, and that divergence is the ruling rather than a drift. The variant is a legibility decision, and the two surfaces carry different content: a bar is glanceable chrome with a handful of large glyphs, this panel is a wall of small type the user is there to read. .regular is the frosted variant — measured 2026-09-07 it composites ~45/255 lighter than .clear, plate luminance ~203 against ~144 on an identical ground — and that calm, light field is what the type sits on. On device, .clear here was simply unreadable (owner, 2026-09-07), which is the whole argument; the greyness it costs is the price of the surface’s job.

The two variants are not the same value in the two toolkits, so there is no single finish for the surfaces to agree on. In AppKit .clear and .regular measured visually identical (#390, 26.5.2) — which is why GlassPlate picking .clear cost the bars nothing; in SwiftUI they are 59/255 of luminance apart. An earlier draft of this entry argued the two surfaces should share one finish and was wrong for exactly that reason: it would have unified a spelling rather than a rendering. Note the direction, too — Glass.regular is the API default, so the panel is on the plain path and it is the bars that take the special variant.

A bar moving to .regular, or this panel back to .clear, is therefore a fresh device sitting rather than an inherited choice.

And .regular’s readability cannot be reproduced with a tint — that measurement is reason 1 above, and it is why “use .clear and tune the tint until it reads” is a dead end.

The chip stays at 0.2 on glass, and that is a decision rather than an oversight. Its separation from the ground is 0.2 × (accent − local composite), and chip and ground share one backdrop: under .regularMaterial that composite was dragged toward a desaturated grey and the delta was near-constant, while glass keeps the backdrop’s chroma, so the delta now tracks the wallpaper and collapses where the composite approaches the accent’s own luminance. That is accepted because the chip’s colour carries no information — the layer name is text, drawn vibrant — so the failure mode is “identity fades on some desktops”, not “the user cannot tell which layer they are in”, and nothing rides the hue for a colour-vision floor to bind. Both escalations are worse: an opaque accent capsule puts the loudest mark in the footer on a passive readout and reads as a control, and an accent stroke swaps an unmeasurable wash for an unmeasurable hairline at fixed luminance — the same objection that defers the panel edge.

The panel keeps its drop shadow (hasShadow = true, which BarPanel deliberately does not have) and draws no edge stroke. Whether glass needs one here is deferred rather than settled: a shadow that suffices for a material panel may not suffice for a more transparent one, and the honest test is a plate that actually refracts, over a light wallpaper and a dark one.

A window states its own accent, and a tree that draws chrome is inside the lens (#1293). Every KiwiDesk window sets .tint(SettingsTheme.accent) at its root, because the retired Color.accentColor reads the user’s system accent and an unstyled Button takes whatever tint it finds — so a surface that states neither renders in the user’s accent inside a kiwi app. The panel’s Shortcuts/ tree was outside ChromeScanRoots and therefore not partly covered but silently exempt, which is why it did.

The shortcuts panel never lists its own opener. The show_shortcuts binding (⌃⌥K, seeded per layer since #602) is dropped from the panel builder’s working set and renders in no band — the one deliberate exception to the panel’s “no bound shortcut is ever invisible” contract. The footer’s dismiss hint is its home: it shows the live resolved combo wherever bindings are live, follows a rebind automatically, and still renders in the empty and unavailable states, which no band does — a row can’t match that. A row would also be self-referential (you just pressed the combo it teaches; no macOS surface lists its own trigger as content) and, pre-fix, it surfaced in Custom, the band that means “user-authored raw Lua” — a first-party seeded default there reads as the user’s own script. Promoting the row to the top instead was considered and rejected: redundancy with the footer is most jarring as the first line read, ahead of the actions the user opened the panel to look up. The editor’s General section likewise stays low — macOS’s own Keyboard pane puts “Keyboard Shortcuts…” below the content, and the menu bar’s “View Shortcuts…” plus the onboarding hint already carry discovery. Consequence to keep: a fresh layer (seeded with only the opener) honestly shows the “nothing bound” placeholder while the footer teaches ⌃⌥K. ShortcutsSelfRowTests pins the suppression — un-suppressing it re-leaks the seed into Custom. (#602, PR #638)

The summoned reference yields the activation it stole — and only where the user chose nothing else. The ⌃⌥K panel is borderless chrome, so show() must activate KiwiDesk for the panel to receive Escape at all: an activation the user asked to borrow, not to keep. On a keyboard-commanded close (Escape, the ⌃⌥K toggle, the layer-switch auto-close) the summon re-activates the app that was frontmost when it opened — left to macOS, the still-active app’s next window takes key, which is the Settings window whenever it happens to be open, landing the user in a window they were not using (#952). Three deliberate boundaries. The yield lives in the GUI summon that stole the activation, never in a Core focus stand-down: at OS level the post-close handoff is a genuine focus event, and Core refusing to follow one would split state focus from real key focus. A click-away dismissal never yields: the click already activated the app the user chose, and re-activating the remembered one would fight the click. And a summon from KiwiDesk’s own Settings window remembers nothing — closing back to Settings there IS the user’s previous context. The residue — the clickless AX re-report of Settings trailing the yield — is consumed by the same dismissal grace the ignored-panel family uses, armed at close for KiwiDesk’s own pid. ShortcutsPanelReturnTests pins the yield gate’s arms and the double-close consume. (#952)

An accessibility steal is returned, not refused (#958). Starting VoiceOver activates com.apple.universalaccesscontrol, and when that process yields, macOS re-activates the most recent regular app — KiwiDesk is an accessory app, so its focused Settings window is skipped in the reactivation stack and activation lands on whatever regular app came before it, 3–8 s after VoiceOver starts (device captures 2026-08-24 and 2026-08-27). The user asked to start a system service, not to change windows. The #952 boundary still binds: the misdirected handoff is a genuine focus event at OS level, and Core refusing to follow one would split state focus from real key focus — so the correction is the #496 shape, keep state on the victim and re-assert it with a direct AX raise, whose coupled activation brings the accessory app back. The debt is narrow by construction: only an accessibility system process arms it, only a window of our own pid can be a victim (a regular app’s window comes back on its own), a click clears it (the user chose), focus coming home fulfils it, it expires on a bound sized past the observed yield window, and it is one-shot per steal — so deliberate VoiceOver navigation moments later is never fought. The accepted trade is the one clickless cross-app focus inside that window being returned once. (AccessibilityReturnTests)

Open-or-Focus cycles in canonical order, never most-recently-used. A repeat press of the shortcut walks the app’s tracked windows in space-creation order, then flat-array order within a space, wrapping — not the MRU ring macOS’s own in-app window cycling keeps. MRU reorders itself with every visit: the third press’s target depends on history the user cannot see, and two windows can trade places forever while a third is never reached. A canonical ring is a fixed loop the user can learn — N presses visit all N windows exactly once — and it is derivable from state KiwiDesk already owns, so the order is testable and never guesses. The trade-off is that the first repeat press may not land on the “second most recent” window the way ⌘-backtick does; anyone wanting MRU semantics already has macOS’s own cycling, which KiwiDesk deliberately does not shadow. The ring is app-scoped, so overflow pile-mates are reachable by construction (they are windows of the ring’s app like any other — the cross-layout navigation table’s pile-exclusion logic is about spatial neighbor search and does not apply). On multiple displays the ring follows the one global space order; scope it per display only if device use shows the cross-display hop misleads. (#637)

Open-or-Focus never touches a minimized window while any window is visible; when none are, it restores exactly one. A minimize is a parking decision — the user said “not now” about that window — and a focus gesture must not undo it. So the shortcut works the visible windows only, and reaches into the Dock in the single case where the alternative is doing nothing at all: the app is running with nothing up on any Desktop (since #1146 a window up on an away Desktop is reached instead), where activate() brings it forward showing an empty screen. It then restores the most recently minimized window — the one parked last is the likeliest one wanted back, and it is an order the user themselves created rather than a guess at which window matters. That order is best-effort: where KiwiDesk was not running to watch the minimize it has none, and the app’s own window order decides. One window, not all: the user parked them individually, and a shortcut that un-parks a session’s worth of windows at once cannot be undone with one press.

A time-windowed cycle reset — treating a press after some idle gap as a fresh cycle — was rejected on the same argument that rejects MRU above: the target depends on history the user cannot see, and the cycle “session” is already delimited by something visible, namely the app staying frontmost. An in-cycle unminimize — letting the cycle walk into minimized windows — was rejected because it makes the focus gesture undo a parking decision, and an overshoot yanks a parked window back into the layout. If demand for reaching minimized windows ever materializes it belongs in a Lua-only verb, never in the default cycle. (#673)

A hidden app holds no tiles, and the same rule covers an app that hides itself. [Principle] A tile is room on screen given to a window the user can see. ⌘H takes every one of an app’s windows off screen without destroying them, so holding their slots leaves the layout describing a desk that no longer exists — and it is not a rare corner: an app built to live in the background hides itself when its last window closes, which is what Discord’s red X does, so the commonest “I closed it” gesture on a chat app arrives here. The window comes back to the space it left, unlike a restore from the Dock, because hiding is not a parking decision about one window — the user aimed it at the app, and unhiding undoes exactly that.

The trade-off is that this is read from the app, not the window: an app cannot be half hidden, so no rule here can be finer than that, and an app hiding one window while showing another is telling AX something else entirely (the window simply leaves the list). The alternative signal — the WindowServer’s on-screen census — was rejected as the trigger: it cannot tell a hidden window from one on another Desktop, which AX also omits, so dropping on it would untrack every window the user parked on a Desktop they are not standing on. NSRunningApplication’s hidden flag answers the narrow question exactly, costs no AX round trip, and comes with a notification for both directions.

Two things a hide deliberately does NOT inherit from a close, though the layout half of it is the same removal. It reports its own window_destroyed reason, hidden, rather than closed: that vocabulary exists so a consumer can tell a real close from a visibility artifact without heuristics of its own, and a bar script filtering on closed would otherwise fire its window-closed trigger every time someone pressed ⌘H. And the close-return raise stands down. macOS picks the next frontmost app itself when an app hides, so a raise racing that choice lands the user somewhere neither of them chose — and, for anyone running mouse.follows_focus, drags the pointer after it on a keystroke that never moved the mouse. State still names the survivor; it simply is not forced there. A close has no such competing chooser, which is why it keeps the raise. (#913) The raise stands down too when KiwiDesk’s own process currently holds an active own DIALOG (#929): when an own transient progress window closes to yield to an update alert or dialog, raising the background workspace window would submerge the own alert. Dialog, not any own key window (#935): the class is a modal window, or an own key window that is neither an NSPanel nor the OwnWindowTiling-marked one — the ⌃⌥K shortcuts panel promises the hotkeys keep working while it is open and floats above the raise’s reach, and the marked Settings window tiles, so beside it the raise is the layout’s own behavior; suppressing the successor for either buried it for nothing. And the stand-down governs every raise-shaped action of the removal it refused, not only the direct raise (#936): a refused removal arms no track z-order restore either, because that drain ends in a focus re-raise of the very anchor the stand-down refused, one settle later — the next genuine mutation’s arm heals the pile, while command-driven arms (navigate, swap, resize, drag) are exempt as explicit user actions.

Close-return focus: closing the focused window returns focus to the previously focused window, same space only — and this is not the MRU the cycling ruling rejected. [Rationale] The #637 argument against MRU is about a repeating gesture: a self-reordering ring makes the third press unpredictable. A close-return is a single step back to the window the user just left — one-deep, and the history it reads is the user’s own last action, so the target is exactly as visible as ⌘W’s native behavior (macOS itself hands focus down the z-order, which is the most recent survivor). Reading #637 as banning this trades the predictable outcome for a spatial successor the user may never have visited. The candidate is one WindowID?, never a stack: a deeper walk-back only fires when the one candidate is already dead — where the successor-slot pick is already good — and each step further back is more of the invisible history #637 rejected. Validation happens at close time against current state, and the candidate must be alive (a minimized one left state, so #673’s never-un-park holds by construction), in the same space (never a cross-space yank; a sticky focused from a foreign space, #414, is how a foreign member enters the history), not native-fullscreen (#670), and not a transient overlay (#671). A candidate failing any of these falls through to the successor-slot pick Space.remove already makes — spatial stability is the right tiebreak once recency has run out, because the forward neighbor inherits the closed slot and focus lands where the user’s eyes already are; an index-minus-one pick would move focus against the direction everything just slid. Fixed behavior, no setting: no peer WM ships a knob here, and if demand materializes it becomes a Lua-only setting later. CloseFocusReturnTests pins all of it.

[Principle]

Sticky has no native cue, so KiwiDesk gives it two marks and ships both on. A sticky window can look identical to a normal one, and unlike focus — which duplicates an OS cue — there is nothing to fall back to. So it gets an on-window mark (top-RIGHT corner — top-left belongs to the traffic lights) and a Space Bar badge (top-LEFT of its glyph — the bar reserves top-right for the group count; an intentional cross-surface difference). Floating gets a badge only in the bar, where tiled and floating are otherwise indistinguishable — on the window itself floating is self-evident. Badges are Space-Bar-only (the per-layout App Bar shows no state badges), survive grouping as an “at least one” aggregate, and have no GUI toggle.

The on-window mark’s switch is unconditional, because the mark does not depend on the Space Bar. It paints on the window, so it is precisely what survives the bar being hidden. Greying it says the opposite — dimming means turn that on and I act — and a census gate: records the same dependency as data, for every surface that reads the census to decide what to grey and what to say about it. A declaration that is backwards is wrong wherever it is rendered, which is why the row carries none on either axis. What earns a gate is the Floating tint: it paints only the Space Bar’s badge, so with the bar off there is genuinely nothing left for it to colour. The two rows sit one card apart and their gates differ, which is that reading applied honestly rather than an inconsistency.

A floor guards a trap, not a choice — so the switch gets no warning, only an honest ?. Turning the mark off costs more than the label implies: the mark also carries sticky’s refusal pills (home-space, can’t-pile, move-blocked), so a refused move stops explaining itself — and not only a dragged one, since refuseSwapOntoTraveler and stickyMoveRefused gate the keyboard and CLI paths too. A cost a row’s own label does not imply has to be said somewhere, which is why sticky.mark.help states it — the row is the surface, and Lua’s unclamped setters (sticky.set_mark, space_bar.set_sticky_badge, the dim_factor precedent) mean a floor would only have moved the state one layer down anyway.

Settings does warn about valid-but-surprising choices elsewhere (profiles.overlap_warning), and the line between them is proximity, not subject matter: that one surfaces a consequence that lands somewhere the user is not — at profile load, on another destination — whereas this one is one click away at the moment of choosing, in the ? beside the switch. Which is why that help text is load-bearing rather than decorative: shorten it back to the glyph and this entry’s argument stops being true. (#414)

One word for the dot — “mark” — with two deliberate carve-outs. (R6/#406.) Five words named one thing: indicator, mark, badge, chip, pill. The wire and every label now say mark (sticky.set_mark, JSON sticky.mark, the StickyMark* overlay types). Two words survive because they name something genuinely different, not the same dot: badge is a small disc attached to a bar item’s corner — the group count badge (top-trailing) and the Space Bar’s sticky (top-leading) and floating (bottom-leading) state badges. One family, three deliberately different corners (see the cross-surface note above): same disc treatment, same groupBadgeColor fill on Automatic. That shared treatment — not a shared slot — is why space_bar.set_sticky_badge keeps the word while the on-window cue is a mark. And pill is the mark’s transient EXPANDED state, not the mark itself. A new state cue picks among these words by where and what, never as a synonym — note the bar’s active indicator is a fourth thing again: it marks which item is current, not a window’s state.

The sticky mark has a transient third mode: the home-space pill. In steady state the mark is a passive glyph, identical on every space. But a tiled-sticky window belongs to exactly one home space, and nothing said which — so when a drag on a foreign space snaps the tile back (the one friction moment the question exists), the mark expands leftward into a pill — “Can only be moved in its home space N” — then auto-collapses. The expand waits for the snap-back to settle first (expanding mid-snap reads as lag) — the wait tracks the live relayout animation duration, not a fixed delay, so a slow or long-travel snap-back still lands the pill only once the window arrives. It is deliberately transient, not persistent: a permanent home-space label would be an always-on caption crowding a tiny corner badge, against “captions label, don’t teach.” It names the home space by its configured Space Bar identifier (SF Symbol or emoji, id/name as fallback) so the pill and the space’s bar tile read as the same place — not a focus/z-order state, since the mark is not a focus cue: it marks every sticky window on every space at once. The glyph stays pinned in the rightmost square through the morph (its screen position never moves), and the pill clamps to the window width so it never overruns its own edge; Reduce Motion swaps the morph for an instant show/hide. (#421)

Refusal and dead-end feedback are two distinct vocabularies — never merged. A move that is refused for a reason (a swap onto a tiled-sticky traveler, homed on another space) explains itself with the home-space pill — semantic, worded, on the window that can’t move, not the one that tried (#435). A move that simply runs out of layout — focus or swap in a direction with no window beyond the edge — gets a wordless rubber-band bounce: the focus ring offsets a few points toward the wall and springs back, the scroll-overscroll idiom, not the login-shake (#436). The split is deliberate: the bounce means “nothing there,” so firing it on a locked-but-present traveler would contradict a cue users are trained to read as a genuine edge — and two cues for one keypress reads as a glitch. So keyboard-swap-onto-a-traveler is pill-only (there is a window there); the bounce is reserved for a true no-candidate edge (the exact .fail("no window … of focus"), never "no focused window"). The keyboard path has no snap-back motion of its own, so the pill’s own entrance gets a small scale overshoot — a third, smallest motion bound to the cue that explains, so a keypress still feels registered, without lending it the bounce’s meaning. The bounce moves the ring overlay only, never the window (an AX/SkyLight frame-set burst on a tight loop would fight the tiling engine’s frame authority and the app’s own edge self-clamp precisely where the cue fires); it rides a Spring + per-monitor DisplayLinkDriver mirroring AnimationEngine, works with the focus border off (a transient overlay carries it, torn down on settle), coalesces key-repeat by retargeting the live spring in place, and under Reduce Motion substitutes a single opacity pulse for the movement. No sound: an all-day tool with constantly-hammered directional keys makes an audible per-wall tick worse than silence.

A third refusal — swapping a sticky focused window onto a target buried in an overflow pile the sticky is itself exempt from — gets its own worded pill (Sticky windows can't be moved to the pile, #438), since the retile would snap it straight back and only reshuffle a neighbour into the pile. It fires on the geometric swap path only, where the piled target is found via the shared cascade detector (#172). This scope is deliberate, not an oversight: Scrolling needs no such cue (its overflow is the scroll, not an OverlapStack pile — a sticky sits in a clamped edge column like any other slot), and the rarer array-order case (a track swap stepping toward a folded overflow) is left uncued for now rather than duplicate the geometric detector against the array-step model.

The sticky/floating marks are a filled state-color pair, defaulting to Automatic. The one sticky glyph reads the one sticky.color, so the on-window mark and the Space Bar sticky badge can never drift to different colors; floating gets its own floating.color (a minimal floating namespace, since floating has no other setting) tinting its Space Bar badge only — it has no on-window mark. The color owns the fill, and the glyph on top is auto-contrasted black/white for legibility (a filled disc shows its hue far better than a thin glyph stroke at the 7–9 pt badge size, and an auto-contrast glyph means any picked fill stays readable — a guardrail on legibility, never taste). The Space Bar sticky/floating marks stay filled discs in the count badge’s family; the on-window mark nests the same filled disc inside its glass square, so the two surfaces read as one mark. Automatic falls back to today’s look on each surface: the badges inherit the count badge’s own groupBadgeColor fill (the default trio stays one consistent color), and the mark drops the disc for the bare neutral .labelColor glyph on glass. The default is Automatic (the empty-hex sentinel), not a concrete brand hex like the other color wells: the mark sits on top of arbitrary third-party window content all day, and the adaptive label color is the only default guaranteed legible against anything behind the translucent plate, light or dark — a fixed hue can wash out or clash. So the shipped look is unchanged for anyone who never opens the grid; color is on-demand. The mark glyph itself changed to infinity (“always / everywhere,” and a single stroke that stays crisp at the 7–9 pt badge size where the old square.stack.3d.up.fill’s perspective smeared); the pushpin family is off-limits — SpaceAssignmentChip uses pin.fill for the opposite idea (a window bound to one space). (#429)

Overrides are visible-but-inherited, never hidden. A per-layout or per-space override row always shows — dimmed with the inherited global value until its checkbox unlocks it, and carrying a left accent once overridden so active overrides form a scannable boundary. Discoverable without an “Add override…” hunt, quiet without a wall of enabled inputs. (#68 §3.4) Rescoped by the entry below: the rule governs any override list that is on screen; it no longer forces every override class to have a screen.

Visible-but-inherited governs on-screen override lists; it does not entitle every override class to a GUI. Two boundaries rescope #68 §3.4 (they do not repeal it — wherever an override column renders, the dimmed-inheritance shape above still holds exactly). First: Simple mode withholds only the offer to create a first override; once any override exists, the column shows on every peer row in both modes, because an existing thing that ran yesterday must never be invisible today. Second: a field class whose GUI cost is a duplicated card may live Lua-only entirely. The per-layout App Bar styling rows are the ruling case (GUI_REMOVED_2026-08): each override doesn’t just add a row, it adds a resolution question to every row above it — “why is my bar 44 pt here and 32 there” is a bug report even when everything works — and the GUI price was the whole card again per layout, 40 rows for a narrow need (a monocle bar that wants to be icon-only). The need is real, so it stays fully available in Lua (monocle.set_app_bar_* / scroll.set_app_bar_*), where the precedent already existed: liquid_glass, icon_source and dim_factor shipped as per-layout fields that deliberately render no row. What breaks if this is ignored: either the Bars page grows back its three near-identical cards, or someone “fixes” the Lua chain’s GUI-lessness by hiding the fields — both worse than the boundary. The two per-layout enabled toggles are not styling and keep their GUI (“Show it in”): they are the only way a layout carries a bar at all, and they own the App Bar editor’s grey. (#678 Phase 2, supersedes the GUI half of #68 §3.4’s scope; owner sign-off 2026-08-02)

A per-space override is eligible only when it is layout-local. A field belongs in the Spaces → Customize… override tier when three things hold: it belongs to the space’s active layout, it resolves before the pure layout calculation (so the resolved value can feed layout math over the flat array), and it has an unambiguous layout default to inherit (the checkbox has a meaningful “off”). That admits exactly the six per-layout override models — BSP, Stack, Scrolling, Grid, Monocle, Track — and nothing else. Explicitly excluded: animations, mouse/drag behavior, borders, quit behavior, keybindings and window rules, profile routing (profile_bindings), and GUI language — none are layout geometry, and several are owned outside profile config (#290). This is parity work over the existing per-layout mirrors, not a promise that every setting is space-wise configurable; a generic SpaceSettingsOverride was rejected for exactly that reason. Two boundary notes: Monocle has a single eligible override, focus orientation (which directional keys cycle the window order and which axis the App Bar follows); Wrap focus is a layout-wide Monocle/Scrolling behavior, deliberately not per-space. The override cell’s count and the saved for other layouts breakdown read one reflective fieldCount over these six models, so a new override field is counted without a hand-kept tally. (#290)

A Floating space still shows its parked overrides as a muted “N saved”, never hidden. A space’s override cell reports the total overrides it carries across every layout. A Floating space has none for its active mode, but it may still hold overrides saved for OTHER layouts — parked, and live again the instant the space switches to a tiling mode. Hiding that count (or disabling the cell to a dead ”—”) on a Floating space recreates the haunted tiler (#458): a space silently carrying tiling configuration the user cannot see, which reactivates on switch-back so the layout appears to “change by itself”. So a Floating space with parked values shows a muted N saved cell that still opens the editor, keeping them reachable; only a Floating space with nothing parked is genuinely inert and disables. This extends Overrides are visible-but-inherited and grey, don’t hide (AGENTS.md §2.7) from the override rows to the space-list cell that summarises them — the count is the scannable signal, and dropping it is the one way the data goes invisible. What breaks without it: a user floats a tuned space, sees the cell blank, assumes the overrides are gone, and is ambushed when they return on the next tiling switch. (OverrideCellState, owner ruling 2026-08-04; #678 8a)

Gaps are uniform-first. One Outer and one Inner slider for the everyday “more breathing room” action, per-edge sliders behind a disclosure. When stored edges differ, the disclosure pre-expands and the master slider disables itself — asymmetric setups can’t be blindly flattened. (#68 §3.14)

The gap preview is a live 2×2 grid, not a layout preview. It teaches the outer/inner vocabulary: a uniform 2×2 shows both gap kinds on both axes, where a skewed BSP-style split would only add noise at miniature size. It tracks the sliders live — each of the six stored values maps through a square-root curve (GapPreviewScale, 0–100 pt → 1–14 pt) so everyday 8–20 pt changes move visibly while the top of the range compresses, and per-edge asymmetry renders honestly as uneven margins. Deliberately not a “what will my layout look like” preview — that would be its own component.

Colors are just the native well; hex entry rides the system panel. The inline #RRGGBBAA field originally kept beside every well (the “hex stays first-class” round-1 call) turned ten color rows into a wall of text boxes. The system color panel the well opens has native hex entry in its sliders pane, so the inline field was redundant chrome and was dropped — the stored value stays a hex string, and copy/paste theme sharing works through the panel. (#68 §3.14, revised)

A palette carries every colour, including the two it used to miss. (#678 Phase 3.) The colour surface is reflection-derived from _color-suffixed keys, which silently excluded the sticky and floating mark tints — their key is a bare color, because each struct IS one mark. That was invisible while the marks were edited on a different page from the shelf. Putting all of them on one page under a “save these as a palette” promise made it a defect: the bridge would have dropped two of its own rows. The filter now admits an exact color as well, and the empty “Automatic” value is accepted on those two paths and nowhere else — without that the surface would be one-directional (paint a mark, never hand it back), and the derived default palette, which extracts the shipped defaults, would carry two values apply dropped. The authored palettes in Resources/Palettes leave both keys out and should keep leaving them out: a mark tint is the one colour whose default is adaptive, so a palette that pins it takes a light/dark-following mark away without saying so. The derived default palette is the deliberate exception — it carries both, empty, which is what makes a reset reach them. This does not soften the colors-only rule below — two more COLOURS joined; no palette gained a flag, a width or an effect.

Palette colors follow a rough matching guide. (#408 follow-up, 2026-07-20.) A palette (the bar + border + drag colors, bundled or user-saved) reads as one system when its roles relate by a few loose heuristics — mostly a guide to eyeball a new palette against rather than a spec the reflection-based ColorPaletteKeys surface enforces. Mostly: four clauses of the colour-vision rule below are build gates over the bundled catalog as of #511 (accent inequality, the separation floor, both accent keys present, both opaque). The rest are advice. Where a clause is enforced this list says so — don’t assume from tone.

  • Hue budget: 1–3 chromatic hues, 2 is the sweet spot. Count only saturated identity hues, not neutrals or the badge red. The common shape is one primary accent + one focused accent; >3 hues is a smell (Monochrome and the deliberately-busier Sunset/Ultraviolet are the ratified exceptions).

  • The two accents must separate on an axis red-green vision loss preserves — lightness, or blue↔yellow. (#470, widened catalog-wide by #511. Enforced.) The primary hue is the one active_item_color, highlight_color and border.focused_color all carry — which bullet states that as a rule, and what enforces it, is the focus-is-one-colour entry below; this one is about the pair that must SEPARATE. space_bar.focused_item_color is the second signal, and the test it must pass is not “a different hue” but “still a different colour after the loss”. Exactly two axes survive.

    The complementary temperature rule of thumb — cool primary → warm focused, and vice-versa — usually satisfies this, because for a cool primary the flip is a blue↔yellow move: True Dark’s #64D2FF / #FF9F0A separates at 241/441 though the two sit nearly equal in lightness. It is only a rule of thumb, and it inverts on a green primary, where the warm side is precisely the collapsing axis. The old default is the proof: #8DB354 / #E8A33D measures 22/441 — one colour to a protanope — while satisfying both “different hue” and “complementary temperature”. A green-primary palette must therefore go down in lightness (the default’s #C2790A, 93, which is why focused reads darker than active) or across to the cool side, giving the temperature flip up altogether.

    #511’s two green-primary siblings took the cool road: Kiwi Neon #F4CA25#2BE0FF (37 → 190) and Kiwi Gold #8DB354#9CE8C8 (49 → 181 — the inverted twin, gold primary against the brand green). Lightness alone would have cleared the floor for both (Gold #B8D095 measures 124, Neon #AA8909 131), so this was not forced. It was chosen for parity with the 181+ band the rest of the catalog sits in, and because those two lightness picks land on a washed sage and a dull mustard that stop reading as their own palette — an eye-confirm call, not an arithmetic one. So don’t “fix” Kiwi Neon back to an amber: green primary → cyan focused with no temperature flip is the decision, not an oversight. Gold’s mint sits at H155 rather than a teal so the palette keeps a green read (#439 rules teal out as a brand hue); Neon’s cyan at H189 is nearer that line and is allowed because Kiwi Neon is an expressive showcase palette, not a carrier of the brand tokens.

    SpaceBarAccentSeparationTests sweeps the catalog and measures this quantity — CVD separation, never a lightness proxy, which would condemn True Dark, a palette with no defect. Its floor is 60: a floor, not a target, set by the default’s 93 and meant to catch disasters rather than to license a new palette at 61. ColorPaletteTests.focusedAccentDistinct pins mere inequality and is subsumed by that sweep — kept as the cheap check, not a second half of the coverage. Bundled palettes only. User palettes and the Lua setters are deliberately not held to it: §2.7’s “the GUI curates, Lua is open” makes an accessibility heuristic the wrong thing for a setter to clamp, and the guard itself cannot see the “genuinely different hue” half of the rule anyway. (Every separation figure here is a Viénot-1999 protanopia simulation in linear sRGB, Euclidean RGB distance, max √3·255 = 441 — SpaceBarAccentSeparationTests computes the same quantity, and pins these very numbers so the metric cannot drift from the argument. Other CVD models give different absolutes: Machado 2009 reads the same two pairs as 28 and 96.)

  • Focus is one color across bar and border. border.focused_color = the primary accent, and highlight_color IS that accent — in every bundled palette, without exception (#756, narrowing this clause from the permissive “borrow the secondary as a flourish” it used to allow; PaletteHighlightRoleTests holds it). The flourish reading did not survive contact with what the highlight actually paints: under active_indicator = outline it strokes a 2 pt ring around the whole active item, and on a plain bar that ring plus one tinted glyph is the entire active state — the item’s own fill is clear. It is the largest mark either bar makes, so a palette that gives it the second hue has the subordinate colour outshouting the item it qualifies. Neither palette loses the hue it gave up: both still carry it on drag.drop_zone.*, where a second signal reads as distinct rather than as competing. Where the focused accent sits is the separation clause above’s decision, not this one’s.

  • A primary that cannot carry the ring is LIGHTENED, never swapped for the secondary. Sunset is why the rule is phrased that way: its #FF375F simulated for protanopia sat 11 from its own fill composited over a white wallpaper — under a fifth of the separation floor — so the ring was covering a defect rather than expressing a theme, and the active glyph, drawn in the same hex, was already unreadable there. Swapping the ring’s hue would have left the glyph exactly as invisible. One lightness step (#FF8099, hue and saturation untouched) fixes both, and the palette keeps its identity. The measurement that decides this is an accent against its own composited plate at both wallpaper extremes, because a translucent fill sweeps the whole grey range as the wallpaper changes and a hue can clear one end while failing the other.

  • border.unfocused_color is always near-neutral grey, low saturation, ~35–60 % alpha — it must never compete with the focused ring.

  • fill_color sets the light/dark base; item_color inverts against it (hover_item_color mirrors the item family, doesn’t flip it). A bundled palette does not pick a bar-fill alpha at all — it carries the shipped one, which is the App Bar entry’s argument and PaletteBarFillTests’ pin; a hand-written config or Lua setter stays free, ~40–85 % being the range that renders as a fill rather than as glass or a wash. Under liquid_glass the backdrop is render-capped (GlassTint.maxAlpha). The cap is a floor on how much refraction survives, not a promise about any particular fill: a Fill at or below it renders exactly as picked, and only what cannot render as glass is bent. It was a scale in an early sketch and a clamp is the ruling — a scale taxes the whole range to fix its top, so a user at 0.30, where nothing is broken, would get 0.15 and a bar that is never the colour they picked at any value (#1297).

  • hover_fill_color ~50 % alpha (0x80) of a hue a shade off the accent — legible feedback that never reads as the active state.

  • group_badge_color defaults to a neutral grey #636366 / white; a bespoke badge echoes the palette temperature and pairs a text color chosen for contrast against that badge. The default was #B00020/white until #955 — the macOS notification badge, byte for byte — and that was the wrong idiom to borrow. A notification badge counts things that arrived and want an answer; a group badge counts windows that are simply there. An alert hue on every grouped item, at rest, before any interaction, spends urgency the state does not carry, and urgency spent on nothing is not available when something needs it. Contrast was never the argument on either side — white clears 7.3:1 on the red and 6.0:1 on the grey — and it is the reason the lighter neutral is not the answer: systemGray #8E8E93 cannot hold white at disc size (≈3.3:1). Grey is also the bars’ own vocabulary rather than an import, Monochrome having picked #636366 for exactly this role before the default did, and a grey this near-neutral (three points of blue and nothing else) is all but protan/deutan invariant, so the colour-vision posture improves for free. What is neutral is the default, not the knob: a theme that wants a hue here still picks one, which is why the four bundled palettes carrying the red as an inherited default rather than as a choice (Kiwi Gold, Clean Light, Slate, True Dark) moved with it while the four that chose their own (Monochrome, Sunset, Ultraviolet, Kiwi Neon) did not. A default retune reaches only what has not stored an answer, and the two stores answer differently. gui.json is sparse — it records what was changed — so a machine that never touched the badge takes the new grey at once. A profile snapshots TilingSettings whole, so every profile saved before this carries #B00020 explicitly and keeps it on load. That asymmetry is worth stating rather than smoothing over, because the two are not the same kind of fact: a sparse entry IS a choice, while a snapshot field is only “the default on the day it was written”, and a user cannot tell from the file which they have. Ruled: left as is — the retune is for fresh installs, and a saved profile keeps what it saved. A one-shot migration over the retired pair was considered and declined, and the reason generalizes past this colour: a migration that chases a default has to rewrite a value it cannot read the intent of, and the file does not record intent. Getting the new default is one deliberate action away — re-apply a palette or reset the colours — and that action is visible and reversible, which a migration on next launch is neither. It also means a retune can look like nothing happened to anyone with saved profiles; that is the cost, and it is named here so the next report of it is diagnosed rather than re-litigated.

  • Drag ghost / drop-zone: a deliberate two-hue split (border opaque + fill ~15–25 %) so origin reads apart from target — and since #511 it is held to the same CVD separation floor as the two accents (DragPairSeparationTests; enforced), because origin and target are the only two overlays on screen at once. Target tracks space_bar.focused_item_color — true for the shipped default and six of the nine palettes; Monochrome, Sunset and Ultraviolet keep a third colour and clear the floor anyway. Origin is a green darkened for stroke duty, but not necessarily the ring’s green: see the overlay note below for why the shipped ghost had to leave the hue family.

The logo’s mark holds one hue across themes; only the wordmark ink is themed (#479). A dark-mode logo exists for exactly one reason — ink contrast on a dark pane — and that is a lightness problem on the lettering, not a hue problem on the symbol. Re-hueing a mark per appearance reads as a different brand, which a young identity cannot afford. The docs site already worked this way before the rule was written down — site/src/styles/theme.css themes the Starlight title ink while its header mark stays fixed — so this generalizes a precedent rather than importing one. So the kiwi symbol is byte-identical in both appearances — there is deliberately no dark symbol master, and logo.svg serves the Settings header, the bundle’s AppIcon, the site nav and the Starlight header in either theme. (The runtime Dock icon it once also served is gone with the activation-policy promotion — an .accessory app has no tile.) (The retired logo_dark.svg is recoverable at 1c135a4:assets/logo_dark.svg — but a future dark variant should be derived from the current green master’s geometry, not from a gold recolour authored against the pre-#439 palette.) Only the wordmark’s ink moves: forest #12251a on light, mist-green #E1EEDB on dark. The kinship with the content-overlays note below is narrower than it looks and worth stating precisely: both refuse to re-hue the identity element — but the overlays reach for lightness to avoid needing a variant at all (one darkened hex survives both near-white and near-black), while the mark reaches for nothing and the wordmark keeps two pre-inked masters. Nor is the ink move itself a lightness-only shift: #12251a#E1EEDB turns the hue as well, because ink has no identity to protect. Only the symbol does. Trade-off: the mark cannot be tuned for a dark backdrop, so it must read on both by construction — it does, because the pale kiwi flesh gives the dark window tiles their own ground regardless of what is behind the mark. What this replaced: a gold recolour of the entire logo, symbol included, authored before #439 made green-forward explicit and never revisited — which was possible only because the master fused the lettering and the mark’s tiles into one path, so nothing could recolour the text alone. That path is now split (see assets/README.md).

The default palette adopts the KiwiCanopy brand tokens (#439). KiwiDesk is one tool under the KiwiCanopy parent brand; the shipped default palette takes the shared brand tokens so the studio reads as one identity. Chrome the app fully controls takes the brand kiwi green directly; the exact hexes live in the struct defaults and bundled.json, not here. One branded sibling leads the shelf after the default: Kiwi Gold (warm gold-fruit variant, green as its secondary) in bundled.json. Bundled dark presets cover three non-overlapping axes — brand-soft (the default), neutral-hard (True Dark), warm (Kiwi Gold); a near-dupe fourth doesn’t earn a slot, and opacity/contrast variants belong in Lua/profile tuning, not a second preset. The authored siblings are hand-maintained: unlike the derived “Kiwi (Default)” (which reads live from the struct defaults via PaletteCatalog.defaultPalette), they do not auto-track a brand-token change — shifting a brand hex means editing bundled.json by hand in the same change set.

Content overlays are the brand green, darkened for duty. The focus ring and drag ghost paint over arbitrary third-party window content. The bright kiwi accent (#8DB354/#AACB5D) is a fill-only color — too light to survive as a thin stroke on light windows (#AACB5D ≈ 1.5:1 on white, fails AA) — so the ring must darken to clear 3:1 on both near-white and near-black. Darkening the accent hue (~84°) on lightness alone gave the old #588613; but at the low lightness a thin stroke needs, that yellow-leaning hue reads as dull olive/moss (#578 — a full-saturation same-hue #538A00 still did on device). So the ring leaves the 84° family, shifting ~12° toward true green to #4A9816 (H96 S75 L34), which escapes the cast and still clears 3:1 on near-white (~3.6:1) and near-black (~5.8:1) while staying unmistakably on-brand green. The ghost shared the old #588613 until #511, and no longer does — see the origin/target paragraph below; the 3:1-at-both-ends bar is what survived the move, the shared hue is what did not. The default ring width is 5 pt (was 2): a thicker stroke is not just more visible, it reads at a more saturated color than a hairline can (a 2 pt line’s anti-aliased edges wash its effective contrast out), which is why the default could move off the duller #567A1F to a livelier green at the same lightness (the #588613 era, since shifted to #4A9816). 5 pt is also the widest that still tiles cleanly with unfocused rings on: each ring reaches its width into the 10 pt default inner gap, so 2 × 5 = 10 fills the gap edge-to-edge without overlap (6 pt would overlap). That couples the width default to the gap default — changing either without the other re-opens or overlaps the ring band. The drag drop-zone keeps a distinct hue as a darkened amber #C2790A (the old #E8A33D had the same light-window problem), so origin still reads apart from target. For the ring, the darkening began as lightness-only — the same move the green-forward identity makes for ink and borders (keep the hue, drop the lightness where a role needs contrast) — but #578 added a deliberate ~12° hue shift on top. It is the one place that “keep the hue” rule is broken, because at the contrast-mandated lightness the 84° hue had no non-mossy option left, so hue moved where lightness could not. The bars keep 84°; only the ring (and, separately, the ghost) leave it.

The drag ghost is the one place that move ran out of room (#511). Origin and target are the only two overlays on screen simultaneously, so they are the only pair that has to separate from each other, and a yellow-green against an amber is exactly what red-green vision loss erases — they measured 4.7/441, worse than the 22 #470 called one colour. The target could not move (its amber is the hex the Space Bar’s focused accent converged onto), so the ghost had to. What it could not keep was the ring’s chroma: stacking the 3:1-on-both-ends bar onto the separation floor caps the ring’s hue family at S0.45, so the ring’s own S0.75 cannot satisfy all three at any lightness — the ghost could not simply be a darker or lighter #588613. Drop to the shipped ghost’s S0.40 and the ring’s hue does qualify, but only just: #799D43 measures 61, a point over the floor, where the emerald band (which caps at full saturation) gives 76 at the same chroma. That trade — chroma against separation, not impossibility — is pinned by DragPairSeparationTests.ringHueFamilyCannotSeparateAtChroma, which is also where to start when re-deriving it; the numbers here are quoted from that guard rather than the other way round. So the ghost alone moved to a deep emerald #347957 — hue 150, S0.40, 76/441, 5.2:1 on near-white and 4.0:1 on near-black, margin on all three while keeping real chroma. The contrast bar held; the hue it shared with the ring did not. The ring has no partner to separate from, so nothing asks it to move for CVD — it later shifted to #4A9816 for the unrelated moss reason (#578), not this one. Origin is therefore green-but-not-the-green, which is the cost, and the alternative (#2F4A0C, a yellow-green at 85/441) was rejected for falling to 2.11:1 on near-black — that would have traded a colour-vision defect for a contrast one. Bundled palettes follow the same rule: target tracks space_bar.focused_item_color in six of the nine, which is what fixed Clean Light, Slate and True Dark, each of which had been shipping origin and target as the same hex (separation 0, for every viewer, not only CVD). Monochrome, Sunset and Ultraviolet keep a third colour and already clear the floor. DragPairSeparationTests guards all of it.

The optional glow inverts this trade for the bloom only: a halo is a fill, not a stroke, so it brightens the ring hue back up (BorderStyle.glowColor) instead of staying darkened. The Space Bar’s own focused_item_color — a separate “viewing-not-active” semantic — converged onto the same #C2790A in #470 rather than getting its own amber. The bar owns its backdrop, so it never needed the drop-zone’s contrast darkening; it turned out to need the identical darkening for a different reason (state separation from the green under colour-vision deficiency — see the lightness clause above), and reusing the ratified hex beat minting a second amber for one palette.

The App Bar has its own sidebar destination. (#229, superseding the earlier “Appearance ends with the App Bar block” note; itself superseded on the destination shape — #293 merged both bars into one Bars destination, and the #678 Phase 2 redesign made it one page of two cards, with the per-layout overrides gone per the §3.4 rescope above. What survives of this entry is the argument: bar configuration is too deep to live inside the structure page — Appearance then, Gaps & Borders now — and stays out of it.) Appearance kept only Gaps and Drag & drop — the everyday controls people revisit — while the App Bar (global style + ~10 colors + per-layout overrides) was the deepest rabbit hole in that tab and dominated the scroll. It became a first-class, deep-linkable destination in the This Profile group, peer of that page. It is not a tab strip alongside Gaps/Drag: those are co-active concerns tuned together in one session, not a mutually-exclusive set, so a strip would misapply the #205 “tabs fit a fixed exclusive set” principle. (Superseded in part, #678 Phase 3: the bar colors left the Bars page entirely for Advanced Colors, and their “Advanced colors” disclosure became “More colors” there — see “Colour is its own destination” below. The at-rest/behind-a-drawer SPLIT survives verbatim: Fill and Highlight are still the two a drawing of the bar most visibly reflects, and are still the two at rest. The per-group preview strip that made that point went with #793’s composite scene; the split never rested on it.)

[Principle] Colour is its own destination, and a colour renders in exactly one of them. (#678 Phase 3, 2026-08-02.) Filing a colour under the thing it paints — the ring’s tint under Focus border, a bar’s tints on the Bars page, the mark tints under Sticky windows — scatters one decision (“what does this look like”) across every page, and it puts nothing in the way of the same hex getting a second editor. That is the failure worth naming: two editors for one value keep separate disclosure state and separate gates, so they disagree about whether the value is even editable, and a user who changes it in one place has no way to learn the other exists.

So the split is by KIND, not by subsystem — structure (does this get drawn, how wide, how round) stays with its feature; colour (what is it painted with) collects onto two pages. A Simple user’s whole colour surface is then a shelf of palettes and one live scene of what they are running — no individual hex, and no reason to open the other page. A user who does want the individual tints meets them grouped by where you see it, because you arrive at that page having noticed something on screen is wrong, not looking for “highlight colour” in the abstract.

The cost is real and is paid deliberately: every gate on the Advanced Colours page now names a switch on another page, so “why is this greyed” has to say where to go, on a live ? outside the dimmed rows. That is the price of the split, not an oversight in it.

Two consequences bind future work. A destination’s title is a search key, so content moving out moves the title with it. Settings search indexes destination titles, so a page keeping a name for content it no longer holds keeps winning the query for that content — Appearance, the most colour-sounding word in the app, would have gone on answering “where do I change the ring colour” after the split left it owning no colour. It is Gaps & Borders, the name the census already gives that area — the name the Home card teaches. And the colour pages sit after the things they paint: search returns one hit per destination in the search catalog’s own order (SettingsDestination.thisProfile + .wholeApp — a separate list from the Home grid’s card order, pinned set-equal to it by HomeCardOrderTests), so a colour page above Bars would answer “App Bar” with a grid of swatches instead of with the App Bar’s own card.

Drag & drop explains itself in plain words. The group opens with one sentence on what dragging does (swap a window’s position with another), and Ghost / Drop zone are smaller subsections — each with a one-sentence caption (“the position your window is dragged from” / “will snap into when dropped”) instead of the parenthetical jargon titles (“dragged window”, “swap target”). Section captions are a SettingsSection affordance, so other groups can adopt the same pattern.

[Principle] The drop target follows the cursor; a cross-display drop MOVES, a same-display drop swaps. (#492.) The drop-zone and the final drop resolve their target from the mouse cursor, not the dragged window’s frame center — a large window dragged onto a smaller display keeps its center over the origin display long after the pointer has crossed, so a center hit-test never reaches the destination slot and no feedback appears. The cursor is where the intent lives, and it alone selects both the destination display and the slot (the slot pool already spans every visible display). Preview and drop share the one cursor rule, so the highlight can never promise a target the drop won’t act on.

Releasing on another display MOVES the window into that display’s active space. Onto a window’s slot it takes the target’s array index, the target and the rest shift up one; over an empty area (an empty monitor, or a gap) it appends. A track destination is the exception: the arriving window follows the space’s new_window rule (e.g. open in a new track), like a freshly spawned window, rather than the positional slot — routed through the same addFocusedToSpace choke point a keyboard / Space-Bar move uses, so track cap / spill placement lives in one place. Because a cross-display drop is resolved before the resize gate, a big window clamped smaller as it crosses onto a smaller display still reads as a move, not a resize. Either way — because a tiling slot exists only where a window sits — the destination display re-partitions to N+1 slots. A same-display drop still swaps the two windows. The destination is the active space of the display under the cursor, so an empty monitor still receives the drop; only a same-display release outside every slot snaps back. The move happens live (#504): once the cursor has dwelt on the destination display for a beat (a debounce, so skimming the seam — or an overflow-inducing crossing that would bounce right back — never re-tiles both displays per mouse event), the window’s membership eager-moves there and both displays re-partition, opening a real slot under the cursor while the dragged window stays pinned under the pointer (dragExemptWindow — its frame is never set mid-drag). This is the Space-Bar-spring model (#372) keyed on displays, and it buys the unification: from the crossing on, the drag is a same-display drag in the destination space — swap on a window, snap into the opened gap, one “you’re inside this space now” rule. Dragging back before release crosses back symmetrically; an abnormal end (window closed or rekeyed mid-drag) restores the origin space and index. A gesture that crossed is a move for the rest of its life: the drop skips the resize interpretation outright — the live twin of the relocate-before-resize-gate ordering — because macOS clamps a big window’s size on a smaller display, which the magnitude test would misread as a resize. The drop-commit relocate path remains for the fast flick whose dwell never fired, sharing one placement choke point (insertDropped) with the crossing so the two can never land a window differently. Sticky windows are the deliberate exception: they never live-cross — their cross-display drop keeps the full #445 gate + pill semantics of the drop-commit path, resolved once at release. Rationale: the primary reason to drag a window to another monitor is to move it there — swap-only would be frustrating, and it can fling a window you never touched onto your other display. Trade-off: this makes cross-display behave differently from same-display (move vs swap), and it is not capacity-neutral — the destination gains a window and the origin loses one, so both displays re-partition. That was chosen deliberately over the one-rule-everywhere swap (which a UI-design pass argued for on consistency grounds): the move model matches direct-manipulation expectation for a monitor-to-monitor drag. The sticky-move guard fires on both paths. The destination is the active space of the display under the cursor, so a tiled-sticky traveler injected onto a foreign display can’t teleport the window to wherever its home space happens to show: a drop whose target isn’t a real member of the cursor display’s space (a foreign-display traveler, or an empty area) is treated as an empty drop and moves the window to that display rather than snapping back with the #435 refusal pill — you were dragging there anyway. The same-display traveler drop still shows the pill.

The track exception keeps the preview honest by suppressing, not lying. A track destination files an arriving window by its new_window rule, not the pointed slot — so the cross-display drop-zone highlight is suppressed over a track destination (handleDragMove), leaving only the ghost. The invariant “the highlight never promises a slot the drop won’t act on” therefore still holds: where the landing is rule-based, no slot is promised. Same-display track drops swap positionally, so their highlight stays.

[Principle] A float crossing displays scales to fit by default; keeping the exact size is the opt-out. (#502, supersedes #444/#493.) Rationale: #444/#493 originally kept a float’s exact size on a cross-display re-anchor — “size is the user’s choice” — and explicitly rejected shrink/center as the default. QA reversed the judgment: because macOS half-clamps a too-tall window’s height but lets its width overflow the screen edge, a float that keeps its size on a move to a smaller display arrives partly off-screen, which reads as broken to most users. So float_scale_on_display_change now defaults on — the window is scaled by the per-axis ratio of the two displays (same relative footprint) as well as re-anchored, wherever a float crosses displays and for floating-mode members too (#498/ #500), still confined clear of the bars. Trade-off: the scale is per-axis, so on displays of different aspect ratio it slightly distorts the window’s aspect, and it resizes floats that already fit — accepted as the lesser surprise versus a window hanging off the edge. Map: the escape hatch stays Lua-only, no GUI (set_float_scale_on_display_change(false)) — the OFF state (“keep my float’s exact pixels, accept the overflow”) is a narrow, technical ask (screen recording, pixel-matched capture), the same GUI-curates/Lua-open call as float_nudge and the bar dim_factor knobs; a GUI toggle would need a paragraph of caveats in its caption, which is contextual-help/Lua-reference work, not a Settings control. A future contributor must not re-derive “size is the user’s choice” from the old #444/#493 record and revert this.

Ghost and Drop zone are two side-by-side columns. (#231.) Each column leads with its own live preview and puts its controls directly beneath, so tuning a column’s border width never scrolls that preview off-screen — the failure mode of the earlier one-strip-then-two-stacked-sections layout. They are a genuine A/B pair (same schema, edited by comparison), which is exactly where macOS System Settings itself reaches for twin panels (Displays’ Arrangement, Desktop & Dock’s light/dark), so twin columns state the pairing once instead of duplicating preview-then-controls structure. What a column keeps is what only that column can answer — whether its border and its fill are drawn at all. Everything a column once asked that the other strokes are asked too has left it: the shared corner radius sat full-width above both until #754, the border width and the alignment picker sat inside each, and all three now belong to the page’s shared card or to Lua alone. The narrowing that let a half-width row hold a slider (dragColumnLabelColumn) and the in-group short form it was for (“Border width” → “Width”, with the full name kept for VoiceOver through a11yLabel) both moved WITH those rows: the last of them left this editor in #754, so it no longer pushes the narrow axis in through settingsLabelColumn at all — what remains there is toggles, which draw their own labels. The pair lives on in Advanced Colours’ twin drag columns, which take the width as AdvancedColorRow’s labelWidth:. The drawing beside those columns went with #793’s composite scene, but the obligation it carried did not: wherever the ghost and drop zone are drawn — the Gaps & Borders panel, since #793 — the drawing shows the alignment, radius and width actually stored, because all three are still settable from Lua. Schematic, not pixel-exact, and it remaps the full value range instead of hard-capping halfway (the fix the retired App Bar preview strip asked for). The alignment drawing earns its keep twice over, the control having been dead before it — SwiftUI .strokeBorder always draws inside.

[Trade-off]

The slot-size unit picker offers Percent and Points only; .auto stays in the model and Lua but has no GUI segment. The three-way picker’s founding reason was that the shipped default could not be expressed as one number (a fixed pt horizontally, a fraction vertically). Once both axes’ standards became the same fraction, a “Default” segment was indistinguishable from Percent at that number by anything the user could do in Settings — and a picker option that no in-app action can ever tell apart from its sibling is removed outright, not greyed (“grey, don’t hide” covers controls another mode reactivates, which this was not). A stored .auto renders as Percent at the standard and stays .auto until the slider is touched, so an untouched config still tracks a future retune of the standard; scroll.set_slot_size(0) keeps that policy reachable from the open layer (GUI curates, Lua stays open — the dim_factor precedent). Removing .auto from the model instead would have spent Lua surface to solve a GUI problem the two-way picker already solves. (ui-designer, 2026-07-29.)

[Trade-off]

A scrolling slot ships at 95% of its axis — near-full, not full. The sliver the last 5% leaves is not spare room: it is the neighbouring window peeking in, and it is the only thing on screen that says the space scrolls at all. A full-axis slot looks exactly like a monocle layout until the user happens to change focus, so the default leaves the hint showing — while the slider still runs to 100%, because a full-axis slot is a legitimate thing to want and hiding a hint is not a broken value (GUI curates, Lua stays open — and here they agree, so neither withholds it). The standard is deliberately not a comfortable-looking 80%: a scrolling space is a stack of near-full windows the user pans between, and every point the default gives back is a point of the window they are actually working in. The cost is the counterpart of the ultrawide trade above — on a very wide display 95% is a very wide column, and the answer there is still an explicit pt or % slot size. The percent slider steps by 1% rather than the coarser step its pt sibling takes, since a percent of a scroll axis is tens of points on any display this app targets; SlotSizePercentRangeTests holds the slider to the model’s own bounds and to a step the standard lands on.

[Rationale]

A short scrolling row does not stretch to fill its axis. (#1356, owner ruling 2026-09-09.) Two windows at the starter setup’s just-under-half slot leave the last few percent of the axis empty, and that remainder reads, from close up, as unfinished work: the obvious “fix” is to stretch a row shorter than the axis until it fills it. It is refused because it fights the one thing a slot size is for. A slot is the size the user set — by slider, by Lua, or by a resize press — and a fill would rewrite it the moment the row was short, then hand it back the moment a third window arrived, so every resize of a two-window row would be undone by the next retile until the row was long enough to escape the fill. Two windows at their configured share resize exactly as they should; that is worth more than the sliver. The lone window is the one exception, and it is not a stretch: with no neighbour whose size it could fight, a single window takes the whole area, the same treatment monocle gives it, because a lone 95% column reads as a broken monocle rather than as a scroll hint — the hint the 95% default exists for is the NEIGHBOUR peeking in, and a lone window has none. ScrollingLayoutTests holds both halves (singleWindow, shortRow).

[Principle]

One width and one corner for all three strokes — the GUI removes the decision rather than building a control to protect it. KiwiDesk strokes three things around a window: the focus ring, the drag ghost and the drop zone. Asked as three independent decisions they were three chances to answer once and forget twice, and nobody holds the preference that comes out of that — a 3 pt ring beside a 1 pt ghost is an oversight wearing the clothes of a setting. So Gaps & Borders asks each question exactly once, in a card above the sections that draw the strokes, and every per-stroke width, alignment and radius control leaves the GUI (GUI_REMOVED_2026-08). The verbs stay open and unclamped, per stroke, for whoever genuinely wants three different ones.

The first cut of this was a Use one width for all borders toggle over two masters, with the per-stroke sliders left on screen and dimmed. That is the wrong shape, and the reason generalises: a toggle that turns a defect on is still the defect, shipped with a switch. It asks a new question (“do you want them linked?”) to protect an old answer nobody wanted, it needs a stored pick and a runtime gate and three dimmed rows to express, and the state it protects — three strokes drawn three ways — is the very state the card exists to end. Where the GUI would need a control to keep a bad option reachable, delete the option. This is not “grey, don’t hide” (#171) overruled: that rule covers a control another mode brings back to life, and there is no mode here that revives a per-stroke width.

Corners passes the exact test alignment failed, which is why one is a control and the other is not. The test is the entry below: can the question be put to all three strokes, or only to two? Square/Rounded can — but only as Square/Rounded. The ring stores a two-value corner STYLE and the drag pair a 0–40 pt radius, and the first cut derived the style from the radius (> 0 ⇒ rounded), which is a slider collapsed into one bit: 1 pt and 40 pt drew an identical ring. A control whose range the thing it drives cannot represent is not a shared control, so the numeric radius left the GUI with the widths and the picker reads AND writes both halves — Square is a square ring and a 0 radius, Rounded is a rounded ring and any radius above zero, defaulting to the system window radius, which is also the radius’s own shipped default.

The picker READS both halves and WRITES only on a pick, and that asymmetry is deliberate. A profile whose radius Lua set to 7 pt displays as Rounded — which is what the drag pair actually draws there — and stays at 7 pt: the getter never stores, so opening the page cannot silently normalise a value the user never came here to change. Re-deriving at load is the alternative and is worse — it rewrites a saved profile on the way past.

Re-affirming a segment must change nothing. Picking the segment already shown is the one interaction where the user named no new answer, and treating it as a write is what would undo the promise above: a stray tap on Rounded would move that 7 pt radius to 16 with the same word on screen before and after, and the header counting a change. So Rounded writes the system radius only where there is no rounding to keep (a zero radius); Square writes 0 outright, being the one shape with a single radius. This is also the behaviour a segmented control has everywhere else on macOS — neither SwiftUI’s Picker nor NSSegmentedControl re-fires for an unchanged selection — and “the GUI is ours” licenses a different LOOK, never a control that acts differently from its twin.

Where the halves disagree the picker shows no segment at all. The two are stored separately and Lua can move either alone, so border.set_corner_style("square") against a rounded radius is reachable — and the Corners row sits directly above a focus-ring preview drawing the ring’s own answer. Selecting one of the two would make the row contradict the picture beneath it; making the preview read the master instead would be worse, since a preview that stops showing what the app draws teaches the wrong thing about the app rather than about one row. So the control asserts neither, which its sliding pill already expresses by hiding, and either segment then converges both halves. The width row cannot do the same — a slider has no blank thumb — so it keeps showing the ring’s width.

Both rows acknowledge a disagreement rather than greying on it. The gap masters, one card up, grey when their edges differ, because there is no single value to show and a per-edge drawer sits right under them to repair from. These two have no per-stroke row anywhere on the page: greying them would name the problem and withhold the only control that ends it. So the acknowledgement is a ? beside a live control — the three strokes are set differently right now; choosing here sets all three — which is what a master owes when it is about to overwrite an answer it did not show.

[Rationale]

Border alignment is Lua-only, and both drag markers are laid inside. Inside-vs-outside is a real choice — at 1–3 pt it moves a stroke by half its width — but only two of KiwiDesk’s three strokes can be asked it. The focus ring outsets its window and has no alignment concept at all, so a GUI control would make the page symmetric in every respect except the one row where it silently covers two of three. A page that asks the same question of some strokes and not others teaches the wrong model of what the strokes are, and the cost of not asking it in the GUI is a value a drag.set_ghost_border_alignment call sets in one line.

Inside, because a marker has to describe its target exactly. The ghost and the drop zone exist to answer one question — where will this window land. Laid inside, the stroke’s outer edge IS the slot boundary, so the marker traces the landing area and nothing else. Laid outside it claims a region larger than the slot by the stroke width on all four sides: the answer it gives is wrong by exactly the amount it is drawn, and wrong in the direction that matters, since the thing being promised is a size. A ring can afford that; a marker whose whole job is the promise cannot.

The ring outsets under a constraint the markers do not share, which is what makes “match the ring” the wrong instinct rather than a competing taste. The ring surrounds a real window whose pixels must not be covered, so outward is the only direction left to it. A drag marker is painted over a target region — an empty slot, or a window the drop will act on — and has no content to protect. Reading the ring’s appearance off it and applying it here copies the result without the reason, and pays the cost with none of the benefit.

Inside is also the geometry that survives every gap. Two outward strokes on adjacent slots exactly fill the shipped inner gap at the shipped width (2 × 5 into 10 pt, the coupling Gaps.Inner documents) and eat window pixels below 2 × width; at zero gaps the ghost’s stroke and the drop zone’s land in the same band, where one hides the other and the drag loses the very marker it is being steered by. Drawn inside, adjacent markers sit flush and both stay wholly visible at any gap. The current stacking is no defence against that and must not be leaned on: both overlays are floating panels ordered front when shown, so the drop zone sits on top only for being shown second. Inside makes the question moot instead of resting on that.

Alignment must not become adaptive — inside at narrow gaps, outward elsewhere. A setting whose effective value is decided by another setting is invisible in both places anyone reads it, the Lua call and the profile JSON, and the verb stops being authoritative: drag.set_ghost_border_alignment("outside") would come to mean outside, unless. It is the objection that already sank the shared width as a stored pick in the principle above, arriving here at the per-stroke value instead of at the master. The verbs stay per stroke and unclamped — the GUI curates, Lua stays open — so an outward pair is one call away for whoever wants it.

[Rationale]

The two bars ship where macOS already puts a persistent strip. (#660; the fill number retuned by #755.) Space Bar on top, App Bar on bottom, both in the plain design language, both filled at the one opacity every bundled palette’s bar also carries — PaletteBarFillTests owns the number.

Each half of that is the same argument. Top and bottom are where the menu bar and the Dock have already taught the eye to look for something permanent, so a new user reads the bars as part of the system rather than as two panels someone stuck on; the previous left-edge Space Bar competed with nothing and matched nothing. plain — one shared plate rather than a box per item — is what the menu bar itself does, and a boxed strip reads as a widget floating over the desktop. And the fill left 40 % because a translucent default is a bet on the user’s wallpaper: 40 % was legible on the dark ones it was chosen against and a guess everywhere else, while opacity is the one axis where the safe default costs the confident user a single setting.

Where it landed is a separate decision, and #755 moved it. #660 tuned one bar in isolation and took the number that was clearly safe. #755 could read all nine bundled palettes side by side, where the spread ran 40 % to 85 % and the readable ones had converged on one value on their own — so every bar fill KiwiDesk ships now carries that one alpha, the built-in default included. The reason it is one number rather than a range: how solid a bar reads is not a per-theme preference. A palette picks hues, and a user who switches theme is not asking for a less legible App Bar. Palettes and Lua stay unclamped either way — this is a claim about what ships, and re-applying a palette is the only migration (a palette paints one-shot, so a config already carrying an old alpha keeps it until then).

None of this narrows anything — all six values stay reachable from Lua and from Settings. It is a claim about which starting point is right when we know nothing about the desktop, which is exactly the “approachable by default” clause: the default is for the user who never opens the editor.

The bars name the WINDOW, not its app. (Owner ruling 2026-08-19, replacing the name / icon_and_name content modes the bar has carried since it shipped.) app_bar.set_content takes icon, title or icon_and_title, and the Space Bar’s front segment shows the focused window’s title in place of its app’s name. The retired spellings are simply gone.

A stale enum spelling costs the FILE, not the field, and that price is worth stating plainly because it is the one the config format already charges everywhere else. TilingSettings decodes AppBarStyle inline, so an unreadable value fails the enclosing decode: a profile carrying one is skipped by allProfiles() — it disappears from the profile list rather than opening at defaults, surfaced as a ConfigIssue.profileBroken with Delete / Reveal. And that is every profile v0.9.7 wrote, not the few whose owner changed the setting: TilingSettings.encode is exhaustive and icon_and_name was that build’s default. The gui.json sidecar is NOT exposed — GuiConfig.encode writes the spaces, rules, bindings and layers, never settings.

Leniency for this one field was refused anyway, and not because that damage is small. The argument for it — an unreadable enum should not take its siblings down — is the strongest one in the area, and it is why it fails: it is not specific to content. Six sibling enums in this struct and every enum in SpaceBarStyle throw exactly this way, so sparing the single renamed field is a coin flip on which field the user gets wrong, not a mitigation. Leniency belongs everywhere or nowhere. Both decode sites are strict — AppBarStyle and the per-layout override.

The crossing is a migration, not a lenient decoder, and the difference is that one of them ends. ConfigMigration rewrites name / icon_and_name in the file itself, once; a decoder that folded them would keep accepting the retired vocabulary forever, because nothing ever signals that the last config carrying it is gone. That is also why the earlier “re-editing the config IS the migration” answer was withdrawn rather than defended: it was sound while this repo had one user, and v0.9.7 shipped to others (AGENTS.md §5, amended). Asking a stranger to hand-edit JSON to get their profiles back is not a migration policy.

Profile and GuiConfig carry a format version integer (#902), following SetupBundle.currentFormat. Migrations key off the format rather than scanning payloads, and future migration removals establish a supported format floor rather than guessing whether older configs still exist.

The bar exists to tell one window from another, and the app name is the one label that provably cannot. Five Finder windows read “Finder” five times while the icon beside each already said so; a sampled desktop (owner, 2026-08-19) had three Finder windows titled Downloads, KiwiCall and keebart-studio, sharing no prefix. The app name is not redundant in general — it is redundant next to the icon that names the same app, which is the only place the bar ever drew it.

The app name survives in exactly the two places a title cannot speak, and there it is never shortened: a collapsed group, whose windows have several titles and no one of them is true of the group; and an empty title, which the lazy-title apps of #160 report for a while after opening — four of twelve apps on that same desktop reported none at all. The group case self-heals, because focusing a group expands it into members that do show titles.

Titles do NOT generally repeat the app name, which is what makes icon_and_title non-redundant: of that sample, Finder, ghostty and System Settings put none of it in the title, Obsidian appended its own name and version, and the browser appended the site rather than the app. Where an app does append it, it appends at the tail — which is the argument for tail-truncating the cap rather than head-truncating it.

A drawn title needs a cap and a refresh path; neither is optional. (Same ruling.) Two consequences fall out of drawing a string the user edits, and both are load-bearing rather than polish.

app_bar.set_title_cap (8–80, default 10) exists because App Bar slots are uniform and measured from the widest item (AppBarOverlay.autoSlotWidth). On that same sample the app names ran 6–20 characters and the titles to 57. One long title therefore widens every slot until slotLength’s quarter-of-the-bar clamp bites and the rest of the bar scrolls — so the cap is what keeps auto item sizing usable at all, and item_size is not a substitute (it answers a different question, and only for users who go looking). The Space Bar’s own cap exists for a different reason and is kept as a separate knob for that reason: its front segment already ellipsizes at the panel edge and cannot clip, but its estimated length feeds the bar’s alignment total, so under center or end an uncapped title slides the whole run of Space items sideways.

The refresh path is the subtler half. The bars are driven from retile(), and TilingEngine.shouldRetile returns false for .windowTitleChanged — correctly, since a title moves no window. Before this change that meant a title event did nothing and nothing needed it to; drawing a title makes the same event a render input. It is handled as one: handleTitleChangedForBars re-renders the bars and never retiles, because retiling on a rename would re-issue a frame set — and, on an app that refuses a size, re-teach the #677 ledger — every time a tab was renamed. It is debounced through a DeferredTasks slot rather than a bespoke flag, so teardown’s cancelAll() reaches it like every other settle (#48), and gated on the rendered content, so a vertical bar (which collapses to icon-only) schedules nothing.

App Bar edge is absolute. (#293, supersedes the #228 axis-relative model.) The stored value is one of the four screen edges (top / bottom / left / right, default bottom) and the bar renders exactly there in every layout — the earlier start/end values that resolved against the layout’s orientation are gone. Axis-relativity existed to prevent an edge/axis mismatch when the edge was derived per layout; with the Space Bar requiring free four-edge placement for both bars, the derivation (and its rationale) fell away. The Settings preview is edge-aware and draws a left- or right-edge bar vertical.

The Space Bar reserves space-first. (#293.) The Space Bar’s strip is carved from the display’s original visible frame, and the remainder becomes the bounds every layout — and the App Bar’s own reservation — operates inside. Layouts never learn the Space Bar exists (resolution before layout; layout functions stay pure over the flat array). Two rules fall out for free: same-edge stacking (Space Bar screen-facing, App Bar window-facing, insets add) and perpendicular corners that cannot overlap (the App Bar strip spans the already-inset frame).

Same-edge bar stacking is a supported layout, not an error. (#293.) Both bars on one edge is a reversible, deliberate choice: no conflict dialog, no automatic relocation, no blocked picker. The GUI explains the resulting order inline; profile load/import accepts it silently.

The Space Bar always groups; there is no knob. (#293.) Adjacent same-app runs collapse into one glyph + count badge unconditionally — unlike the App Bar’s group_adjacent_windows toggle. The asymmetry is structural, not an oversight: App Bar items are click targets, so grouping changes interaction and earns a toggle; Space Bar glyphs are informational, and the glyph cap depends on grouping running first (an ungrouped mode would burn the cap on duplicates while conveying less). That ordering is the settled part; the cap’s value is a knob (space_bar.set_glyph_cap, default 5, range 1–12, #376) — group first, then cap, whatever the cap is. The overflow badge’s +n counts hidden windows, not slots — the same unit as the per-glyph count badges and the item’s accessibility label.

The Space Bar’s two-accent model. (#293.) Three tinted states, all GUI-exposed inline (never behind a disclosure — the system is the bar’s defining signature): item_color paints inactive Spaces, active_item_color the active Space’s identifier and glyphs, and focused_item_color the focused window wherever it shows — its glyph inside the active Space AND the front-app segment’s glyph and text (QA 2026-07-19: the front-app segment IS the focused window, so it belongs to the focused accent, not the active-Space one; each accent now maps to exactly one concept — the Space vs the focused window). The focused accent is a deliberately different hue and a step darker (amber #C2790A in the Kiwi theme, #470), not a tint of the active green — a lighter shade of the same hue washed into “active space” and the two states read as one, and hue alone does not survive colour-vision deficiency against a green primary (see the lightness clause in the palette-coherence heuristics above). It has two surfaces, not three: the group-count / +n badge text was the third until #470, and is now the deliberate exception — badge ink stays group_badge_text_color and never takes the focused accent. A glyph and a badge do not share a background (a glyph’s ink is contrast-tested against the bar plate, a badge’s against a second, independently chosen fill), so the alpha half of the 2026-07-20 uniform ladder generalizes and the ink half only ever held against the one badge fill it was eyeballed against — #470’s darkening took that pair to 2.10:1. Nothing is lost: untintedAppAlpha already puts the focused app’s badge uniquely at full alpha, beside a glyph that is tinted. It also restores the grain the App Bar’s own count badge and the sticky/floating state marks already had, and matches the platform — the system badge pairs one ink with one fill unconditionally, with no focused variant. (That fill is red and ours has been neutral grey since #955: what generalizes is the invariance, not the hue.) The corollary for a bespoke badge still stands: choose its text colour for contrast against that badge, not from the accents. In Settings the Focused window row greys out (#171) when its only surfaces are untintable: native-image glyphs and no front-app text shown. Emoji identifiers and native app images stay untinted; shape (the active indicator) carries the active state there, plus a half-strength alpha dim on inactive Spaces (alpha respects “never tint” while still reading as inactive), so color is never the only signal. A thin divider (the front-app segment’s rule, shared helper) separates the identifier from the glyph row inside every occupied item.

Space Bar content is fixed in v1. (#293.) Identifier plus app glyphs — no clone of the App Bar’s Icon | Title | Icon & title chooser. The identifier is structural and the compact glyphs are the point of the overview; a labelled-glyph mode needs its own demonstrated use case first.

Space identifiers are icon-only, with settled fallbacks. (#293, revised QA 2026-07-19.) The configured Space icon (SF Symbol | emoji | single character) renders alone — no emoji-vs-name chooser. Without one: a numeric id renders as its plain tinted digits, any other id as a two-letter uppercase monogram (“mail” → “MA”). The earlier N.square SF Symbol fallback was dropped in the bar: a self-bordered glyph inside the default boxed background read as a box-in-a-box, and the symbol only existed for 0–50 — plain digits unify both fallback paths and have no range limit (capped at three digits so the square cell never clips). The shortcuts panel’s symbol rows keep N.square deliberately: a symbol slot in a plain list row has no boxed wrapper, so the rationale doesn’t apply there.

The Space Bar ships enabled. (QA 2026-07-19.) It is the only surface where KiwiDesk’s Spaces are visible at all — macOS’s Desktops have Mission Control and gestures; ours have nothing else. “Approachable by default” is better served by a new user seeing the core organizing concept on first launch than by a cleaner-but-mute one. The App Bar stays per-layout (monocle/scrolling default it on; other layouts off).

The front-app segment is per-display. (#293.) With space_bar.show_front_app on, each display’s bar shows the focused window of the Space that display currently shows — not the globally frontmost app (sketchybar’s front_app). One bar per display means per-display content, consistent with every other per-display fact in the bar; a secondary display shows its own space’s remembered focus.

The bars honour Reduce Motion, and the ring MARKS rather than counts down. (#1078.) A bar is chrome KiwiDesk draws, so it owes the same stand-down the Settings window gives: the setting asks for less motion from the app, not from one window of it. Two rulings shape what “less” costs, because the bars’ motion is not decoration in either case.

  • An item run LANDS. A bar whose run changes width is telling you a group expanded or a window closed, and the arrangement is the content — the travel between arrangements is not. This is the ruling the layout schematics already took (#1069): the new frames still appear, so nothing is lost but the slide. A bar is glanceable rather than studied, which argues the same way and more strongly: motion in the corner of the eye is precisely what a Reduce Motion user asked to be rid of.
  • The spring ring MARKS its item. The sweep is a countdown, the one piece of bar motion that carries information a static frame cannot, so standing it down does lose something. It keeps what it can: the ring is still drawn, and the quiet pre-delay survives, because a delay is not motion and it is what keeps a quick flick-to-relocate from flashing a loading ring. So the item stays bare for the same half-second and then wears a whole ring for the rest of the dwell — “a hold here will spring” is intact, and only “for this much longer” is gone. The alternative, dropping the ring entirely and leaving the hover tint to say it, was rejected: the tint fires on every item the cursor crosses, so it says here rather than armed.

The engineering half — one home, BarMotion, and the guards that keep the bars routed through it — is .claude/rules/bars.md ▸ the bars start motion in one file.

Space Bar drag-drop is a two-speed spring, not a blind relocate. (#372.) Dragging a window onto a Space item either relocates it (fast drop, move_to_space) or, after a 2 s dwell, springs the view to that Space so the window is dropped into its live layout. A first design pass rejected spring-loading over a cross-process race fear; it was reconsidered once grounded in the code, because KiwiDesk’s Spaces are virtual (a retile, not a WindowServer Space change), which narrows the risk to one place. The load-bearing details, so they are not relitigated:

  • The dragged window is exempted from stashInactive for the gesture’s life (TilingEngine.dragExemptWindow), the same kind of pin as the existing !isFloating exemption — otherwise the spring’s retile would stash it under the cursor mid-drag.
  • The spring uses a private activate-plus-retile helper, not focusSpace: that command warps the cursor to hand off AX focus, which would rip the pointer out of the OS drag loop. No focus hand-off, no warp, and the spring retile is animated: false regardless of animations.on_space_change (a crisp switch must not add motion competing with the live foreign-app drag).
  • Space membership flips eagerly at spring (QA revision): the window is moved into the target the moment the view springs, so the live drag shows the ordinary drop preview (ghost + drop- zone) in the target’s layout and the release lands it in the exact slot. An earlier design flipped membership lazily at drop to avoid stale state, but that left no preview during placement. Eager membership needs no rollback: an abnormal end (window closed / tab rekeyed) means the window is gone, so stranding is moot, and a normal drop is meant to place into the sprung space — cancelDrag only tears down the gesture bookkeeping (pending spring, dragExemptWindow); it does not, and need not, move the window back. The dragged window is exempt from all frame application in retile for the gesture’s life — both the layout loop and stashInactive, via dragExemptWindow — so the spring’s retile places the target’s OTHER windows but leaves the dragged one under the cursor. Without the layout-loop exemption the retile yanks it to its computed slot mid-drag (a small dwindled BSP corner, say). Because the move commits at spring, window_moved_to_space fires then rather than once at drop, and once per spring — a chained A→B→C dwell emits two moves. That cardinality change is deliberate; hooks keyed on the event see the intermediate moves.
  • The dwell defaults to 1.5 s and is user-configurable (space_bar.spring_delay, clamped 1000–4000 ms; a Spring delay slider in the Space Bar editor). Longer than Finder’s ~0.7 s: the ring sweep shows progress and a whole-view switch is a bigger disruption than a folder opening, so the accidental- trigger floor sits higher. The sweep animation tracks the configured value, but only starts after a fixed 0.5 s quiet pre-delay (SpaceBarDropCoordinator.springPreDelay) so a quick flick-to-relocate never flashes a loading ring; the spring still fires at the full dwell, so the sweep fills over dwell − 0.5 s, and the range floors at 1 s to keep that fill visible. The pre-delay is carried by the stroke animation itself, so leaving before it elapses shows nothing. Always-on, no enable toggle; focus-after-drop is not a new setting (move_to_space_and_follow already models following). Option-held-drop → follow is a deferred second gear.

Bar alignment is edge-relative, one shared default. (#293 QA.) Both bars place their content run via alignmentstart / center / end, values edge-relative (a left bar’s start is its top) for the same reason edge is absolute: correct on every edge without a per-edge remap. One default (center) for both bars and every edge — never per-edge defaults. The Space Bar’s pre-QA left/top anchoring was an omission, not a decision. Once an App Bar group overflows and scrolls, the three alignments deliberately collapse to the scroll offset; the control is not greyed for it (a static preview can’t know real overflow). Copy-appearance copies alignment (arrangement is appearance); edge stays excluded (placement is not).

The Space Bar scrolls the whole bar when the Spaces overflow. (#385, retiring the #293 stage-2 “spaces are a small, bounded set — clip, no scroll” assumption once a 100-Space case was stated.) It reuses the App Bar’s overflow model rather than inventing a second one: an arrow zone (BarArrowView.zone, 24 pt) reserved at each end while the run overflows, the item viewport inset by that zone plus a gap, clickable chevrons toward the hidden Spaces, and a scroll that follows the active Space into view — the three alignments collapsing to the scroll offset exactly as the App Bar does. Items are not shrunk to fit: a Space item is a drag-drop well (#372), and shrinking the target a dragging cursor must land on, at the moment precision matters most, is worse than scrolling. Because a Space item is a drop target — unlike a click-only App Bar tab — an off-screen Space would be unreachable mid-drag, so a drag dwelling over an arrow zone autoscrolls the bar (SpaceBarOverlay+Scroll, 0.2 s dwell then a step every 0.3 s; proposed defaults, not user-configurable — no new knob). This never contends with the drop-spring: the arrow zones are chrome structurally excluded from every item’s hit frame (hit frames are clamped to the visible viewport), so a drag cursor is over an arrow XOR a Space item, never both, and the autoscroll and the spring govern disjoint zones with no shared dwell state. The front-app segment is the tail of the one aligned run, so it scrolls with the items (reachable at the forward end) rather than pinning at the rim — pinning would carve a second permanent reserved region and break the one-run invariant for a narrow combo (100+ Spaces and front-app on and caring while scrolled); an explicit pinned-segment mode stays an easy additive follow-up if real use proves it painful. The arrow view is shared with the App Bar (BarArrowView, style-agnostic — each bar hands it resolved BarArrowColors), with the one addition the Space Bar needs: a setDragHover synthetic-hover path, because a foreign AX-tracked window drag delivers no mouseEntered, so the arrow would otherwise go dark during the one gesture it exists for.

The two bar cards share one canonical row order. (#374, re-cut by the #678 Phase 2 census render.) At rest: existence (the Show toggle / the Show-it-in switches), Position (with the same-edge note under it, in both cards), Thickness, the content toggles; the Style disclosure then runs background → indicator → symbol style → sizes, and colors live in the colour cards — signature colors inline, the rest behind a shut “Advanced colors” disclosure in both. Differences remain only where the bars genuinely differ (front-app segment, glyph cap, spring delay, the copy action). A new bar row must slot into this order on both sides — in the census order lists (BarsRowOrder), not a per-card invention.

The copy action copies structure, never colours. “Copy sizes and style to Space Bar…” takes the shared structural fields (thickness, background, indicator, sizes, roundness, symbol style) and deliberately excludes the *_color class — derived by suffix, so a new shared colour field stays excluded automatically (SpaceBarParityTests.copyAppearanceParity). Colours are the palette’s and the Advanced Colours area’s concern; a colours-copy, if it ever ships, lives there — a copy that also painted colors would silently overwrite a palette the user applied on purpose, the same category of surprise the palette entry below bans in the other direction. (Owner ruling 2026-08-02, during Phase 2 device review.)

The bars ship one thickness, 40 pt, on every screen, and the slider’s floor is the Core floor by derivation. (#1359, owner ruling 2026-09-13.) Two numbers used to answer “how thick”: a Core default of 32 with a laptop starter of 28, and a GUI band that began at 30 — so the starter’s own value sat below the slider, and one touch of the slider lost it for good. A stored value the GUI cannot reach is a defect, not a curation, so the band’s floor is AppBarStyle.minThickness rather than a number beside it (BarSliderBandTests); the ceiling of 80 stays the GUI’s, Lua open above it, the same split the glow slider takes. The default is one number because the reason for a thinner laptop bar — “a laptop cannot spare the chrome” — did not survive use: 40 read well on every class, and a per-class thickness is a second default a user has to know about before the slider’s number means anything (BarThicknessDefaultTests). No migration is owed, and the reason is specific rather than borrowable: both bars’ thickness predate the first tag (v0.9.0), and the settings encoder writes both bar groups whole (AppBarParityTests, SpaceBarParityTests hold each field encoded), so every file the app ever wrote carries its own number and only a fresh seed takes the new one — a leaf younger than a shipped release, or one a group elides, owes the #1369 crossing instead (BarSliderBandTests, BarThicknessDefaultTests).

“Which palette am I on” is computed, never remembered. (#757.) The shelf marks the card whose colors the config it is editing currently carries — it stores no “last applied palette” anywhere, and there is deliberately no third modified state between applied and not.

The cheap alternative is to record the name on apply, and it is wrong for the reason the one-shot paint below exists: applying is a paint, not a link, so the moment a user edits one hex in Advanced Colors the stored name describes something that is no longer on screen. A picker whose entire job is to show state would then be the one surface in the window that lies about it — the same defect the live-preview rule names, one shelf over. The computed answer cannot: the mark’s disappearance is the honest report of a hand edit, which is why no “modified” state is needed to explain one.

Two consequences worth stating so they are not read as bugs. No card marked is a normal, informative state — it means the colors are the user’s own. And more than one card can be marked, because the question is “do these colors say what this palette says”, not “which card was clicked”: save your current colors while wearing a bundled palette and your copy IS that palette. Ranking them would mean telling the user that one of their own palettes is not the colors they are looking at. ColorPaletteMatchTests holds both, and the comparison is by parsed color rather than by spelling, so re-typing a palette’s own hex in lower case does not read as leaving the theme.

A palette is a color recipe; a Profile owns the colors. (#375.) A palette is a named color recipe you apply once to overwrite the active profile’s colors; a Profile is the persistent, addressable configuration — tiling, layout, and sparse behavior overrides — that owns those colors afterward. So the palette shelf is a colors-only, one-shot paint (the copyAppearance model — never a live link), and the palette library is global, not profile-scoped: scoping the recipe book per-profile would fragment a palette you saved while editing one profile away from the next, for no gain, since profiles already own the color state a palette writes into. A palette is a sparse map keyed by the same fully-qualified color paths the profile JSON uses (app_bar.fill_color vs space_bar.fill_color — bare wire keys collide between the two bars), so it is not a TilingSettings field and never widens the profile schema; it lives in its own global palettes.json plus a bundled resource. This colors-only scope is strict: no palette carries a non-color effect. Kiwi Neon briefly forced border.glow on via a name-check in the GUI apply path; retracted in #578 — it was one-directional (a later sober palette, being color-only, could not clear it, so glow stuck on) and a category error (picking a color swatch silently flipping an unrelated Focus-border toggle the user may have set on purpose). A palette that wants to point at an effect links to its control instead of writing it — Neon’s swatch carries a link that reveals the Glow toggle. Do not reintroduce a magic-name effect side-effect here; if a future palette genuinely needs to recommend non-color settings, that is a schema-level “recommended settings” sidecar, not a name-check. The nine built-ins are read-only with reserved names (a user palette can’t shadow one — rename/delete are omitted, not greyed, because the constraint is never-meaningful-for-this-kind, not mode-inert); “Kiwi (Default)” is derived from the shipped struct defaults at load, so it never drifts and doubles as a reset. Escalating to a full design-package (bundling geometry, fonts, icon source with colors, or a tab restructure) waits on a real signal that people want to share the whole look as one artifact — not merely “more than nine palettes,” which save/export/import already answers. Every bundled palette keeps space_bar.focused_item_color a different hue from its active accent (the two-accent rule, QA 2026-07-19) — Monochrome included: color is the only channel the focused-window state has, so even a mono palette carries one deliberate accent (#FFD60A) rather than erasing the state. Since #470 that rule carries a second clause: the pair must also separate under red-green vision loss (see the palette-coherence heuristics above, which carry the numbers and the reasoning). Every bundled palette satisfies it — the two green-primary siblings were retuned to a cool focused accent by #511 — and SpaceBarAccentSeparationTests measures that, catalog-wide. User palettes are not held to it; the shelf curates, Lua is open.

“Automatic” is a value; “Auto” is an adjective — and the readout column was widened to say it. (R6/#406, owner ruling 2026-07-25.) The word rule itself is a control convention and lives in docs/ui-patterns.md. What belongs here is the cost it was allowed to impose, because a contributor would otherwise undo it as an obvious tidy-up.

An Auto-gated slider prints its sentinel as a word rather than “0 pt”. Under the rule that word is Automatic, which did not fit SettingsMetrics.readoutColumn at 64 pt — a constant whose comment said it was “sized for the widest value in use (2000 pt)”. Two alternatives were weighed and rejected: keeping “Auto” as a documented compact-surface exception (the 384 pt popover precedent), and dropping the word entirely for a dash. The owner chose the full word, so the width was paid.

The bill came to 8 pt, not 20, because the font was the real cost. The readouts had been monospaced, where even narrow letters are padded to digit width; they now use the proportional system font with monospacedDigit() — System Settings’ own idiom — which keeps digit runs tabular so magnitudes still stack down a column, while letters render at natural width. Measured at 13 pt: “Automatic” 61.3 and “2000 pt” 48.5 proportional, against 72.3 and 56.3 monospaced. So the column is 64 → 72, and the per-Space popover 384 → 392 to pay it back on the app’s narrowest editing surface.

The column stays shared: letting the Auto row size independently would keep the trailing edges aligned but leave ragged track ends within one section, and jump on every toggle flip. And it stays trailing — the readout’s outer edge is the pane’s right margin, so trailing is the only alignment that pins it to one line down the whole pane. Centring pins neither edge; leading would fix the slider-to-number gap at the cost of a ragged pane margin, trading something seen while dragging for something seen always (ui-designer, 2026-07-26, after the owner asked).

One fact keeps the shrink from reading as a bug, and it is easy to lose: the word only ever renders on an AutoGatedGroup-gated row, so it is always dimmed and disabled beside full-size numbers — a slightly scaled word there reads as inert, not broken.

The background control names WHERE the background is drawn, not what the rectangle is called. (R6/#406, owner ruling 2026-07-25.) The picker shipped as “Tab background” on the App Bar and “Item background” on the Space Bar. Both are false half the time: under plain no item draws a box of its own in steady state — there is one bar-wide plate (BarPlate), so the background belongs to the bar, not to the item. (Hover still reveals a box per item; that is a transient affordance, not the configured background.) “Tab” fails from the other side, since an App Bar entry is a window or a same-app group, not a browser tab. The control is not naming an object; it is choosing where the fill goes — per item, or one plate behind all of them. Hence “Background style” on both bars (options unchanged: Boxed / Plain), wire tab_backgroundbackground_style, which also makes the two bars finally name the same picker the same way. The rejected alternative was “Item background everywhere”, which reads correctly under Boxed and is a lie under Plain — that asymmetry is the whole reason the name moved, so do not restore it. Item geometry did become “Item size” / “Item gap” (box_sizeitem_size, box_gapitem_gap): those stay true under either style, because items have size and spacing in Plain too, they simply draw no box of their own.

A bar entry is an “item”, not a “tab” — except in gesture prose. (R6/#406, owner ruling 2026-07-25.) An App Bar entry is a window or a same-app group; it has none of a browser tab’s semantics, and the Space Bar’s entries were already items. The colors said item_color / active_item_color from the start, and T4 moved the geometry to match (item_size / item_gap), so item is the model noun — labels, help strings, captions, enum doc comments, and the Lua reference all use it. The one carve-out is the user guide’s gesture prose (“click a tab”, “drag a tab”), where the word names what the reader touches rather than what the model holds; that paragraph is deliberate, not an oversight. Note this is unrelated to macOS native tabs (TabReconciler, §5), which keep the word because they genuinely are tabs — a blanket rename must never reach them.

Plate reach is its own property, not a fourth background. (QA 2026-07-19.) background_fit (full | hug, default hug) answers “how far does the shared plate reach” — a different question from background_style’s “where is it drawn”, so it is orthogonal, and plain gets the choice as well as Liquid Glass (folding hug into material alone would have locked plain full-width forever). Hug wraps the run plus one item gap per end (the Dock’s read — the calmer default for “approachable by default”) and falls back to full while the run overflows and scrolls, where content fills the strip and there is nothing to hug. Inert under boxed (no shared plate): the GUI greys the control, per #171. One geometry authority: BarPlate.frame, shared by both bars and pinned by BarPlateTests.

One Liquid Glass switch governs every KiwiDesk surface that draws it. (#1307, 2026-09-07.) The finish shipped as two independent per-bar settings, and the ⌃⌥K shortcuts panel drew glass unconditionally beside them (#1295) — three surfaces, two switches and a constant. One row on Colours & Animations now writes all three, and the panel’s leaf is stored beside the bars’ in the profile.

Profile-scoped, and the alternative was not merely riskier but unbuildable. Moving the value app-wide into gui.json needed a migration to carry it out of the profile file, and ConfigMigration.migrated is (Data) -> Data? invoked independently by four readers on four separate files with no channel between them; no step crosses a file. Worse, N profiles → one app-wide value is an election a byte-level step cannot hold — it cannot know which profile is active, and a profile the user never loads is never migrated — while a SetupBundle, one blob carrying config and [Profile] together, could perform a carry the live config directory could not. Same data, different outcome per reader, which is AGENTS.md §5’s “reaches EVERY reader” failing silently rather than refusing. The only safe app-wide shape was an explicit DROP of everyone’s stored setting, and #1255 does not licence one: it refused to CARRY a value the encoder wrote, where this is a row a user ticked. So the panel follows the active profile. The cost is real and accepted: switch to a Desktop bound to another profile and the panel’s material follows it.

The switch means ALL THREE, and its ? carries what a boolean cannot. Owner ruling: off is a true statement whenever they are not all on, and a flip writes all three either way. That leaves two-of-three indistinguishable from none-of-three, so a divergence sentence appears in the help while they disagree — reachable only from hand-written Lua or an imported profile, never from the row. Both the switch’s value and that sentence read the one LiquidGlassAgreement, so the control and its explanation cannot contradict; the same discipline as agreedCornerStyle.

Glass OFF for the panel is .regularMaterial — the material its pre-macOS-26 branch already draws, promoted to the designed off state rather than a new surface being invented for it. Each surface keeps its own colour rule: the bars tint their glass from their Fill, the panel stays untinted (#1295). The switch changes only WHETHER, never WHICH.

Reduce transparency stands every glass surface down, live, and moves no stored value. (#1374, owner ruling 2026-09-13.) The platform does not do this for us: NSGlassEffectView draws its material with the setting on, measured live and after a relaunch under it, pixel-identical to the setting off. So while it is on, the bars draw their Boxed or Plain shape with both fills at full alpha — the setting asks for opaque backgrounds, and the bundled …B3 Fills are a 70 % plate, which is not one; the hover fill replaces the box fill under the pointer, so it goes with it — and the ⌃⌥K panel its .regularMaterial, measured opaque under the setting the same day. Each surface’s own OFF state made opaque, not an opaque glass invented for the case, for the reason the entry above gives the off state: a surface that degrades to something nearly glass claims a finish the machine is not drawing. A fully transparent Fill stays absent: it asked for no plate, and no plate is opaque. The liquid_glass leaves stay as the user set them, because the setting is the machine’s and the profile travels: writing the leaf off would flip the user’s choice on every Mac the profile reaches and leave it flipped when the setting goes back off. It is read at render time instead. What this does NOT do, and knowingly: the Settings row keeps reading On while the setting holds glass down — a greyed row with its reason is the honest shape and needs a runtime gate of its own (#1418).

The per-layout override is deliberately outside the switch’s reading. monocle.set_app_bar_liquid_glass and scroll.set_app_bar_liquid_glass still shadow the global, and the row neither clears them nor reports them. That is the behaviour every per-layout override already has — the App Bar thickness slider says nothing about monocle.set_app_bar_thickness either — and the master could not clear one if it tried, so reporting it would state a disagreement while withholding the control that ends it. The divergence sentence is therefore about the three GLOBAL leaves only (code-reviewer, 2026-09-07, which found the row reading On over a glassless monocle App Bar).

The stored keys did NOT merge, which is why there is no migration at all: three leaves, one row, through SettingKey.masterWrites. Both set_liquid_glass verbs and the per-layout liquid_glass field keep working, so this entry does not disturb #678 Phase 2’s per-layout precedent, and icon_source / dim_factor keep the grounding they take from it. A new top-level set_shortcut_panel_liquid_glass reaches the panel’s leaf — the change ADDS Lua reach rather than capping it.

Liquid Glass is an orthogonal finish toggle, not a third background_style. (#390; revised 2026-07-20.) It was first shipped as a third BackgroundStyle case (material) beside boxed/plain, on the reasoning that a toggle would be ambiguous (“boxed + glass” = glass boxes or a glass strip under opaque boxes?). On-device testing (macOS 26.5.2) forced a rethink on two fronts. First, NSGlassEffectView’s own tintColor carries no hue at all — measured on macOS 26.6.2, red, green, blue and the shipped moss each move the composite by the same achromatic amount, over an opaque backdrop and over the bare desktop alike (per-channel spread ≤ 1.6/255, capture noise), and that amount tracks the colour’s alpha alone: −26/255 at α 1.0, −18/255 at α 0.70. It is a colourless dimmer driven by alpha, so glass is a finish, not a colorable surface that could be a peer of the solid shapes. .clear and .regular differ on the same reading — .regular composites ~45/255 lighter over the same backdrop — and the bars draw .clear. Second, the ambiguity dissolves once each combination has a defined rendering: boxed + glass = a glass view per box (grouped in an NSGlassEffectContainerView), plain + glass = one shared glass plate. So the model is now shape (boxed | plain) × a separate liquid_glass: Bool finish that lays over either. fill_color still tints the glass (#408) — not through tintColor, which carries none of the hue, but by placing a solid colored view behind the glass, which the glass refracts into its hue (the way the Dock and Control Center tint their glass). That backdrop is the only channel a Fill reaches the glass on: driving tintColor from the Fill beside it dimmed the plate by an amount GlassTint.maxAlpha did not govern, which is why the bars read as a near-solid slab until #1297. GlassTint is the one place a Fill becomes a colour, and it takes the hex rather than a colour so no call site can hand one past the cap (GlassTintCapTests, GlassTintSeamTests). This is distinct from the earlier degraded-render bug: the items stay embedded as the glass’s contentView (the required usage); the colored view is an additional backdrop sibling behind the whole glass, supplying a hue for it to sample — never a replacement for the content. A fully transparent fill_color leaves the glass clear. One seam owns the five hosting modes (GlassHosting / GlassTint, #407).

The finish is ON by default on every surface (owner ruling 2026-09-10, reversing the launch-day no-glass default: the bars are the app’s face, and a fresh install or a Reset All Settings should show the finish the app is designed around rather than ask for it). A leaf whose ABSENCE changed meaning owes the §5 crossing, because absence was a stored value: a profile written before v1.2.0 carries both bar leaves and no panel group at all, so read under the new default it says bars off beside a panel on — the state the one row exists to make unreachable — on a plain upgrade. ConfigMigration therefore fills a file below the floor: each absent BAR leaf takes the false its absence meant, and the panel — which had no surface before v1.2.0, so no leaf a user could have set — takes the two bars’ agreement where they agree and false otherwise, since the bars are the user’s stated opinion about glass, and a flat false would have minted the same divergence at the other polarity for a setup that had glass on. The step reaches the two shapes that carry TilingSettings by path, never by the look of an object (GlassDefaultMigrationTests, ConfigMigrationGlassRoutingTests). An existing setup keeps the look it had, and only a fresh seed takes the new default. Below macOS 26 the toggle is absent, so a setup authored there carries the stored value wherever it is imported, the finish included.

The finish stays OS-gated: ignored below macOS 26 (glassEnabled = liquidGlass && glassAvailable), and its Settings toggle is hidden there — an OS-capability gate, so absent not greyed (grey-don’t-hide is for mode-inert controls, not missing OS capability). The stored liquid_glass value still round-trips everywhere (portability). Explicitly out of scope: a glass border/stroke, a shadow (BarPanel is deliberately shadowless), and vibrancy-following text.

The glass variant is pinned from the Fill, because macOS decides it per view and the verdict sticks. (#1308, settled 2026-09-07.) The two bars, same Fill, same plain + glass, rendered 114 vs 68 luminance in their item-free plate rows; an in-process log showed every KiwiDesk input identical on both (hosting mode, plate/tint visibility, tint alpha, z-order) and one thing different: NSGlassEffectView’s adaptive content colour scheme, which the OS decides PER VIEW from the backdrop that view samples and then holds until a far brighter backdrop flips it. Either bar can be the dark one — the App Bar usually is, because the bottom edge launches over the darker band of a wallpaper — so a reading that names one bar as “the dark one” is reading the state, not the cause; the fix is a rule both bars follow, never a correction to whichever bar looked wrong. The ruling: the variant is DECIDED, once, from the Fill, in GlassTint.apply — a dark Fill pins .darkAqua on the glass view; a light Fill pins nothing, so the glass carries the app’s appearance, NSApp.appearance as the Settings Appearance pick writes it (#678 item 8, above): dark under a Dark pick, and under Light or System the OS’s own light scheme, which the bright tint holds — so two bars sharing a dark Fill cannot diverge, and two sharing a light one match wherever the pick is Dark. Precedence, ruled here because two sanctioned writers now reach one view: the Fill decides where it is dark, since the plate IS the Fill’s colour and the dark variant is the one the ink reads on; the Appearance pick decides only the light branch. A user who picks Light for KiwiDesk’s own windows and a dark bar Fill gets a dark bar, which is what they asked the bar to be. Every bundled palette gives both bars one Fill, and all but Clean Light a dark one. Three measured facts fix the shape. Only dark can be pinned — measured on macOS 26.6.2 under a light app appearance, where .aqua is what the bars already carry: pinning it changes nothing and the material keeps adapting under it, so a light pin would be a private _adaptiveAppearance write, refused. Whether .aqua pins light under a Dark app appearance is unmeasured, and nothing depends on it. The dark variant is the legible one on a dark Fill: on the shipped moss, palette ink #EAF3EE is 4.2:1 on the light variant and 8.2:1 on the dark, and the active #8DB354 FAILS at 2.0:1 on the light against 3.9:1 on the dark — the light variant both bars launched into was the worse plate. .regular is no escape: it adapts the same way, and its light variant puts the ink at 1.9:1. The threshold is wantsLightInk’s, the one the mark glyphs already use, so “this fill wants light ink” and “this fill wants the dark glass” are one rule (GlassTintPinTests; the one-home clause in GlassTintSeamTests). Residue, stated and on the Accepted limitations page: a light Fill — Clean Light’s #F2F2F7 is the bundled one — still adapts per view, so two bars on it could in principle diverge over a very dark ground; unmeasured, and the bright tint dominates what the glass samples.

Background style and active indicator are orthogonal. (#228.) The old coupled style enum (pills / segments / underline) conflated two orthogonal concerns: the per-item box rendering and the active-item marking. The redesign splits them into background_style (boxed / plain) and active_indicator (ring / edge_mark / gap), so all combinations are expressible — e.g. boxed + edge mark (the old “segments” look), plain + edge mark (the old “underline” look), boxed + ring (the old “pills” look). The two render rulings (settled 2026-07-14 by UI designer): plain × ring is a pure inset stroke in the highlight color (no fill, keeps plain boxless); boxed edge mark insets its ends by the corner roundness to sit flush inside the curve.

App icon rendering is one global choice with two honest options. (#294.) icon_source — GUI label “App symbol style” — offers app_image (System default) and app_font (Glyphs). Decisions folded in, 2026-07-17/18 (ui-designer consult + user direction in chat):

  • Global in the GUI, per-layout only in Lua. A per-layout override row for icon rendering has no user story (it exists in the schema because the field mechanically mirrors every other bar style field, and stays there as power-layer depth); the Settings surface shows exactly one dropdown, directly below Content, greyed while Content is Name (#171 grey-don’t-hide). Accepted side effect: the per-layout override chip counts a Lua-set icon_source override even though the override editor shows no row for it — the chip discovers fields by reflection on purpose, and hiding Lua-only depth from it would be the bigger lie.
  • Glyphs follow the bar’s state text colors (normal / active / hover) — one color system with the labels. Glyph-less apps keep their native image.
  • A synthesized Tinted mode was built and stripped (with its tint_appearance sub-setting): the system-wide Icon & widget style already covers the want for System default icons, and a luminance-ramp approximation misrepresents Apple’s plate-regenerated styles. Dark / Clear / Tinted as true in-app choices remain API-blocked — see Accepted limitations; #362 tracks the private-IconServices probe that could add them.
  • The glyph map format is JSON (icon_map.json vendored from upstream): decoded directly in Swift, validated once, cached — keeps bar rendering independent of the user’s Lua VM. The Lua and shell forms upstream ships were rejected (coupling static vendor data to an interpreter buys nothing).
  • Vendored, not user-supplied: the TTF + map ship in the app (CC0-1.0), refreshed by scripts/update-app-font.sh which pins the upstream release in UPSTREAM.md. CC0 waives copyright but not third-party trademark rights in the depicted app marks — accepted deliberately pre-release; revisit at public 1.0 with the other distribution decisions.
  • The shortcuts panel follows the GLOBAL symbol style: with Glyphs active its Apps band leads with the same ligatures. The panel spans all layouts, so a Lua-only per-layout icon_source override deliberately does not steer it.

[Principle]

A Desktop is its stamp, not its number (#1147). Mission Control’s Desktop number is a positionindex + 1 over the space list — so it moves whenever a Desktop is added, deleted or dragged, and whenever a screen is plugged in or out. Keying durable per-Desktop state by that number means the state silently re-points to a different Desktop, with no event to notice and nothing on screen to show it. KiwiDesk therefore mints a private identifier into each Desktop’s own settings and keys by that; the number survives only as the label a row is drawn with, refreshed from each reading.

Three alternatives were measured and rejected, so none is re-proposed. Apple’s own space uuid is the name field, so it is not ours to write, and it is absent on the primordial Desktop. The internal id64 looks stable and is not — a screen’s Desktops are destroyed and rebuilt around a disconnect. And doing nothing leaves the silent-wrong-Desktop failure, whose stake rises sharply with #1230, where a Desktop’s whole persisted Space set rides on the same key. The measurements behind all three, and the persistence mechanism the stamp rests on, are .claude/rules/os-private-apis.md’s to hold.

The design degrades rather than breaking if macOS ever stops carrying the stamp: a Desktop that comes back unstamped falls back to its Mission Control number, which is what every binding used before this. A Desktop the user genuinely deletes takes its stamp with it and its binding goes dormant — kept, badged, and never fired for the Desktop that inherited its number.

A profile row counts what the profile OWNS, never what it resolves to. A profile carries a sparse diff over the global config — its own keybindings are the rows it overrides, not the rows that fire while it is loaded. So the row’s subtitle counts screens, spaces and shortcut overrides, and a profile that overrides nothing shows no such segment at all. The alternative reads “18 shortcuts” on every profile, which states the opposite of how overriding works: a user who believes each profile owns a keybinding set will go looking for the other seventeen when they edit one, and will read an inherited binding’s disappearance as data loss. The rule generalises past shortcuts — any count on a row that represents a diff is a count of the diff.

[Rationale]

A preset card draws screens, not spaces. One tile per space reads at four and becomes a row of identical stamps at ten, and at no count does it answer the question a multi-screen preset exists to answer: which screen gets what. Screens are the one thing that stays legible from one display to three, so the card draws an outline per display carrying the layout that display’s first space opens in, and the space total goes underneath as text. What is given up is per-space detail in the thumbnail — which #859 gave back before applying, in the preview sheet the card’s own Layouts button opens (see “the panel’s object is the DRAFT” above); the Spaces page still owns it once the preset is applied. Read the thumbnail’s job as identifying a preset rather than describing one: the moment a card is asked to describe, the answer is a surface of its own.

A saved profile’s screens answer by ELIMINATION where they can, and stay blank where they cannot. A saved profile says less than a preset: a preset plans positionally, while a profile pins spaces to monitor fingerprints and leaves the rest to the Main role — which is resolved live, not stored. So a screen the profile does not name draws its outline and no glyph, the same refusal the preset card already makes, and inventing a glyph there would put a claim about behaviour on screen that loading the profile might not produce. But refusing too much is its own defect (#959): saving pins only the spaces that are NOT on the main display, so on an ordinary two-screen profile the main monitor is precisely the covered screen carrying no pin, and its outline drew blank beside a caption announcing six Spaces. Where exactly one covered screen carries no pin, the follows-main spaces have nowhere else to be — that is elimination over stored facts, not a guess about hardware, and it is as reliable as any pin. Two blank screens stay blank, because then the unpinned spaces genuinely fit on either. Read the general rule as: refuse where the file is silent, never where it merely declines to repeat itself.

[Principle]

The rule that picks a profile is written down, not inferred from the badges. Screen-count matching with a default preference is the single most consequential behavior on the page, and it was previously discoverable only by watching profiles load and guessing. The card states the rule and then answers it for the live machine, naming why this profile answered — a Desktop binding, an exact monitor set, the count’s default, or a built-in layout when nothing saved matches — because those are different promises. An exact set stops matching the moment the hardware changes, and a count default keeps matching whatever monitors are plugged in, so a user who reads only the profile’s name cannot tell which of the two they are relying on.

A card that explains a rule states the WHOLE rule. The first cut asked ProfileManager.match and stopped there — which is only the display half, because a Desktop binding outranks matching — so on any machine with a bound Desktop the card named one profile while another was on screen, with the card that creates those bindings sitting directly below it. Half a rule stated confidently is worse than no card: it teaches a model the app does not follow, and the reader has no way to notice. Hence KiwiCore.profileVerdict, one query carrying the same precedence the live paths use, rather than a GUI-side composition of two answers that could be ordered wrongly a second time.

[Trade-off]

A control the OS has made meaningless is greyed, not left live under a warning — and #888 ended the instance by ending the meaninglessness. While “Desktop N activates” named no single event under separate Spaces, #678 ruled the binding rows greyed rather than live-under-a-warning: leaving the menus live let a user configure bindings that could not do what the row says. That grey was correct for as long as its premise held — and #888 removed the premise rather than the ruling, by giving the trigger a definition (the main screen’s Desktop) that holds in every display mode. The rows are live in every display state now, so the grey, its inline warning and its Desktop & Dock button retired together (the stored-profile grey, a different premise, stands). What survives, because it never depended on the instance: a control the OS genuinely makes meaningless is still greyed rather than left live, and existing config stays visible under any such grey — hiding a user’s own configuration to protect them from it is the worse failure.

And greying a control never removes the only way out of the state it describes. The greyed rows were the only place a binding could be cleared while the runtime kept firing it, so the warning carried Clear all bindings — an escape hatch present exactly while there was something to clear. With the rows live, each binding is cleared on its own row, the trap the hatch opened cannot form, and the hatch retired with the grey. The general rule outlives its instance: when a grey covers the last affordance that can undo the thing being greyed, the grey owes an escape hatch, or it is a trap wearing the costume of a safeguard.

[Principle]

A Desktop binding fires only for its profile’s screen count (#1394, #1332). A binding outranks monitor matching, and a profile is saved for exactly one number of screens, so the two used to collide: a Desktop bound to a one-screen profile loaded it onto two screens, where nothing could save it — a profile refuses a set of another count — and Settings could only say save as new instead, which the binding would then not point at. Worse, the two doors that load a binding disagreed about the result: the boot and Desktop-switch door marked the misfit clean, the monitor-change door let it stand dirty, so a restart showed a Screens drift row that a Desktop switch did not. The ruling settles both by construction rather than by picking a side: a binding fires only where its profile is saved for the connected screen count, and for any other count it stands aside so the rungs below — exact set, count default, built-in — answer, exactly as a binding whose profile cannot be read already did. A bound load therefore always fits by count, the unconditional clean mark is gone, and both doors read one verdict from the apply. Screen identity was considered as the extra dimension and rejected: a binding is already filed under the Desktop’s own stamp, and a Desktop lives on one screen, so the external’s Desktop 2 and the built-in’s Desktop 2 are different bindings by construction. The count is the dimension that was missing, and it needs no stored field, since the bound profile carries it. What this does not give — one Desktop loading a different profile per screen count — needs several bindings per Desktop and a gui.json format crossing, and is its own feature. The Desktops row keeps a standing-aside binding and badges it for N screen(s), in keeping with don’t hide: the binding is not broken, it is waiting, and the badge narrates Core’s verdict rather than re-deriving it. With no displays known — the first config load runs before the loop publishes displays, and a paused engine discovers none — the gate cannot judge and the binding waits too, rather than loading a profile that the boot scan’s monitor change would then replace; that first monitor change fires it.

[Principle]

A screen left with no space is healed, never refused (#1175). Every space resolves onto one screen, and nothing stopped every space from resolving onto the other one: init.lua could pin a screen’s last space away, and a profile of named spaces saved for one screen, loaded dirty onto two, left the second with nothing to show, no place for a window to land and nothing for the bars to address. Refusing the config was considered and ruled out (owner, 2026-08-31): a refusal makes pin order matter — the same pins pass or fail by which line runs last — and turns a config that reads as valid into an error. So the resolve heals instead: whatever left a screen empty, it seeds one numbered space there, in the layout the starter setup would open that screen in, and pins it to that screen so the next resolve keeps it. It is one mechanism where every relocation ends, because a heal on one door leaves the other empty. The seed is a live space like any other: no file learns it until you save — a Keep or a Settings Save captures it — and an explicit reload prunes it with every space the profile does not declare. One residue, accepted: a re-dock onto the live profile’s own screen set re-seats the seed in its opening layout, so a layout you set on it by hand does not survive that re-dock, where a declared space’s would.

[Rationale]

One representation, and it is the arrangement itself. A card on this page is a drop target, and a drop target must be identifiable by eye: the user aims at a rectangle, so they have to know which monitor on their desk it stands for before they release. Identity and order cannot tell them — three same-named cards in a row say nothing about which one is the portrait panel on the left. So the cards are the real arrangement, at their own sizes and positions, drawn from the live frames. This retires “macOS’s Displays pane owns true spatial layout — identity + order is enough here”, the earlier ruling that folded the old tab’s three representations (proportional canvas, drag palette, resolution list) into one row of equal-sized cards in physical x-order: enough for a list of displays, and that consolidation kept the drag. Equal-sized cards can only come back together with the drop. (#68 §3.13, #678 turn 13b)

Drawn from POINTS — not pixels, and not physical millimetres. Position is the reason, not fidelity: a display’s global position only exists in point space, so sizing from EDID millimetres while positioning from points would tear the picture into gaps and overlaps that exist in neither space, and macOS publishes no physical arrangement to re-derive it from. CGDisplayScreenSize is EDID and unreliable besides — zeros for virtual, AirPlay and projector displays, wrong values on real panels — and a 0×0 monitor drawn silently is worse than a slightly wrong proportion. Points already track physical size in practice, because people scale a display to a comfortable UI size at their viewing distance, and System Settings ▸ Displays ▸ Arrangement draws in points too, which is the surface users compare this one against. Pixels are the one option ruled out outright: a Retina display drawn twice the size of an identical non-Retina one is a picture of the framebuffer, not of the desk.

A drop target has a minimum size that a truthful picture does not — so the scale is clamped, and the clamp is stated. Two clamps: the scale has a floor, so the smallest display is never drawn too small to hold one space chip (past that the picture scrolls rather than shrinking further), and the largest:smallest drawn ratio is capped, so one ultrawide cannot reduce everything beside it to slivers. The cap shrinks the outlier around its own centre, which can only open a gap and never move a rectangle onto its neighbour. A clamp that engages silently reads as a wrong arrangement rather than an approximate one, so the page says so — but only once the difference is visible. A caption pinned to an everyday desk teaches people to ignore captions, and the cap is easy to trip imperceptibly: a laptop beside a 4K reporting its full 3840 points is over the ratio by under two percent (measured at 1:1 scaling — at macOS’s default HiDPI scaling that display reports far fewer points and does not approach the cap at all).

What the picture cannot say, it says in words. Two facts have no rectangle: which space is up on a display right now (selecting one answers it), and the fact that two identical monitors are a single identity to KiwiDesk — name:WxH is what a pin is stored against, so a pinned space may open on either. The list this page replaced hid that second one; a picture cannot, and an unexplained duplicate reads as a bug in the drawing rather than a limit of the identity.

Chips: pinned, follows-main, automatic. Semantic micro-icons (pin, arrow) rather than border styles alone (accessibility), and automatic is drawn as an outline rather than a dimmed capsule — dimming is this app’s inert vocabulary, and an automatic chip is the one most worth dragging. Four routes to move a space — drag it, its right-click menu, the same menu as VoiceOver actions, and the keyboard chord on the focused chip (#845, which also made the chip focusable at all) — all off ONE builder through the rowActions seam. The whole-chip Menu that once made the keyboard route real consumed the mouse-down that .draggable needs, which is why the restored route is a chord and a hidden anchor rather than a control shape that takes the drag (the argument lives on SpaceAssignmentChip; the ruling is ▸ The row menu’s keyboard route, #845). The clear affordance never takes a slot in the chip’s FLOW — the ⓧ rides the trailing-top corner as an overlay, and hover may change only its tint, never its presence or any metric — because the chips are sized by a flow layout whose arithmetic (MonitorCardChips.minChipWidth) must hold for both states, and both a hover-revealed button and an in-flow trailing slot have shipped and died of that measurement. It is not free of the layout, though: a chip that HAS a badge reserves 6 pt more trailing padding to sit under it, which that arithmetic absorbs because minChipWidth is the narrowest chip and capacity is an upper bound. This passage used to say the two kinds “measure identically”, which #1240 found reading as licence to assume equal metrics. Decoration may ride the accent (the main card’s bloom); the answer never rides hue alone (the “main” badge).

[Principle]

A rule is a sentence, and the sentence is the control. An app rule states what an app does — “Spotify opens in media and floats” — so the row states it, with the two menus sitting inside the statement where their values complete it. The shape it replaced was a form about an app: a name header over two labelled facet columns, which made the reader assemble the meaning out of three fragments and read “Float: Never” as a setting rather than as behaviour. Three consequences bind anything added here, and the first is the one that is easy to get wrong twice.

The word order is the translator’s. A sentence with controls in it is one localized frame with positional specifiers, split on those specifiers and emitted in whatever order the translation put them — never connectives authored as their own keys between fixed stack positions. That shortcut is the harm .claude/rules/localization.md names by title, and it is not theoretical here: ja and ko are verb-final, so no catalog edit could have produced a grammatical row. SentenceFrame is the split, SentenceFrameTests holds it, and the next area that wants a sentence inherits both.

The values are verb phrases, in EVERY facet. A menu inside a sentence has to read as part of one — a noun that completed a “Float:” label completes nothing in a statement. Converting one facet and not the other leaves the row half-built in exactly the state most rows are in: the unset space facet is what every float-only rule shows.

The labels the sentence stopped showing become the controls’ accessibility names. A sentence gives a screen reader no name for its controls, and the settings census names those rows by the same keys — so the call site is load-bearing three ways at once, and dropping it is silent in all three until a locale prunes the key.

A rule whose effect you cannot read off the rule gets a live check. A title fragment is that case: “Windows titled Info” looks obviously right until it also catches “Information”, or misses “Get Info” because the match is case-sensitive. Neither failure is visible in the rule’s own text, and both are visible in the user’s actual window titles — so the editor lists the app’s open windows with the verdict beside each, updating as the pattern is typed, and a rule is verified before it is saved rather than after it misfires. The obligation that carries: the verdict comes from the ENGINE’s matcher, never from a contains written beside the preview. A preview that disagrees with the engine is worse than no preview, because it is trusted — and the two details a re-implementation gets wrong are exactly the two the check exists to show.

This is a read of live window state from Settings, which is not the live-apply coupling #123 rejects: it reaches the snapshot the app already keeps, never the accessibility layer, so a keystroke costs a filter over an array the GUI holds.

[Rationale]

One row per app, two facets. “Finder lives on space 2 but its Get Info windows float” used to be two entries in two differently-shaped lists. Now each app has a Space facet and a Float facet; the App:Title colon syntax is assembled by the GUI and never shown (it’s serialization, not UI). Storage is untouched, so hand-written configs round-trip. (#68 §3.11)

[Principle]

A half-loaded config is visible state, not a log line. KiwiCore publishes the issues of the last config load (broken init.lua, unreadable gui.json, undecodable profile JSONs); the menu-bar icon shows a distinct config-error badge (permission warnings still win — without Accessibility nothing works), and a standalone Config Issues window is reachable without opening Settings. Profile issues also refresh on save/delete, so repairing one clears its badge immediately. (#68 §3.7, #39/#31 own the validation cores)

An undecodable profile is greyed, never hidden. A profile whose JSON won’t decode yields no summary, but hiding it stranded a broken file with no reachable remedy (#246). It now stays listed everywhere — a Delete (and Reveal in Finder) on its Config Issues row, a greyed “couldn’t load” row with a Delete in the Settings profile list, and a disabled entry in the quick menu’s Switch Profile submenu (the remedy is the same panel, one entry up). Grey-don’t-hide (#171); re-saving was never reachable for a file that can’t be read, so the warning no longer suggests it. (#246)

A typo is non-fatal, but never invisible. An unknown call on KiwiDesk or a layout namespace table is a guarded no-op (logged with a did-you-mean), so one wrong name can no longer abort init.lua and silently kill every keybinding below it. The flip side — non-fatal would mean unnoticed — is closed by recording each load-time hit as a config issue feeding the badge and window above. Runtime hits (a typo inside a keybinding closure) only log; a persistent “config error” badge for a transient slip would mislead. (#39)

The quick menu is for daily driving. A healthy menu opens straight on Layout (the most-used control), then Switch Profile (load_profile’s quick path) — same topic, no separator between them — then Settings… low, next to Quit, where every native menu-bar extra keeps Preferences. Warning rows (Window Management Paused… when Accessibility is missing, the starting count while boot is still scanning, Config Issues… when a config load failed) appear only when they apply, at the top, fenced by a single separator that is itself present only when a warning fired. They rank permission → starting → config: without permission nothing tiles however far a boot got, and a config problem is a thing to fix once the app can be clicked at all (▸ Boot: the wait is narrated, which argues the middle one). Menu entries stay monochrome template symbols; the colored tiles are a Settings-window device. (#68 §3.10, §6.2)

Deliberately not in the menu: a header row naming the live profile (the active profile is already checkmarked in the Switch Profile submenu — a permanent top line is near-zero-info chrome above the thing you came for); a permanent Accessibility Settings… deep link (a standing nag for the 99% granted case — the paused warning row covers the untrusted case, and onboarding’s own “Open System Settings” is the fix path); and a Support row (it lives in Settings ▸ About as a discreet link — a menu opened daily for Layout is no place for a recurring support ask). Trimmed from thirteen possible rows to ~six, each of which either does something you came for or is app chrome you expect near Quit.

The real logo ships pre-rasterized, no asset catalog. Vector masters live in /assets; the app bundles plain PNG/TIFF copies regenerated with macOS built-ins (assets/README.md) because swift build on CI runs no actool. The menu bar and quick-menu header render the mono mark as an 18 pt template TIFF (macOS tints it; the old SF Symbol stays as missing-resource fallback). About swaps between two pre-inked wordmark rasters by colorScheme and needs no backing badge — the lettering is artwork, not text, so the ink is baked at rasterization time rather than tinted at runtime. (#68 §3.8/§3.9; the badge and the “cannot follow dark mode” constraint both went in #479, which split the fused path.)

[Principle]

An accessory app that is present must answer. The startup scan used to run as one synchronous block — ~10 s on a heavy session (109 running apps) — and it held the very run loop the menu-bar item and the ⌃⌥K panel are served from, so an icon the user could see answered nothing, and the silence ended with every window on the desk retiling at once. Both halves are the same root: work that long cannot own the main actor. The scan is a queue now, drained a chunk at a time with the run loop handed back in between (#801).

A readiness state is only honest if the surface behind it works. A “starting” badge over a menu that still will not open is the same broken read with a costume on, which is why the chunking is the prerequisite and not the polish. With it in place the signal reuses conventions the app already has, and adds no surface: the menu-bar mark keeps its glyph and is drawn dimmed (lightness, because the bar tints template images itself — and because hue is the channel colour-vision deficiency takes away), the quick menu opens on a disabled row carrying a determinate count, and Layout / Switch Profile are greyed rather than hidden (#171 — they work in a moment, which is what dimming is for; the count row is the sentence that explains the grey). The mark returning to full strength IS the ready signal (#802).

[Trade-off]

The count is apps looked at, not apps adopted — over a queue of apps worth looking at. Both halves earn their keep. The queue admits only apps a boot pass can act on (EventLoop.bootPassAdmits): faceless helpers and ignore-listed apps can never attach, and on a heavy session they are most of NSWorkspace’s list, so counting them narrated the whole process table — “apps: 3 of 145” over a desk showing five, a number that reads as scanning things nobody asked about. Within that queue the tally is apps visited, never apps attached: an app can refuse its observer, so an attach tally can stop short of its total and reads as a progress bar that stalled. Visited reaches its total, which is the difference between accurate and honest. The same number is what the tour’s grant screen shows while it is arranging, so a user who reads both surfaces never finds two.

A screen that claims a finished job must wait for it. The tour’s post-grant screen used to be reachable only after boot finished — because the Continue button was itself blocked by it — and it told the user their windows have been arranged. Chunked, the same screen is read mid-scan, so the claim now waits for readiness and the screen narrates until then. Rejected alternatives, all of which reintroduce something already ruled out: a full-screen boot overlay or brand animation (a takeover that fires uninvited at every login for a permanent accessory app, steals focus mid-keystroke, has no multi-monitor answer, puts brand chrome in a boot path — and, with the main thread starved, would freeze its own animation, exhibiting the hang it exists to excuse); a transient HUD near the menu-bar item (an uninvited window at every login, needing a dismissal policy and an anchor under a notch); and a command queue during boot (a retile queued at second 2 firing at second 9 is a worse surprise than the one being removed, and a new state machine paid on every boot to save seconds on heavy ones).

One slow app is deferred, never abandoned. Chunking cannot divide a single app’s AX work: on the measured session one app’s reconcile took 5011 ms of the startup sweep’s 5285 ms total — so a per-app wall-clock budget (500 ms: clear of the 100–300 ms Electron/WebKit band, inside one AX messaging timeout) drops what is left of that app’s boot work, names it in the log so the outlier stays diagnosable from a field syslog, and completes it unbudgeted after boot, one app per turn (#803). Deferral with completion rather than a timeout that abandons stragglers: an unadopted app’s windows must eventually be managed, which is what the census-gated heal (#675) exists to guarantee and what this spares it.

A bulk reconcile asks the WindowServer before it asks Accessibility

Section titled “A bulk reconcile asks the WindowServer before it asks Accessibility”

[Principle]

An app that tracks nothing and shows nothing is never asked. Accessibility is the only reader that can say what a window is, and the only one that can block: a message to an app not servicing AX — one App-Napped with every window on another Desktop, or a headless agent — returns when the messaging timeout fires, not before. The bulk re-sync a Desktop switch runs used to send that message to every observed app, and on a session with a handful of such apps every switch stalled the main actor for ~1 s per app, in series, with the arrived window’s ring, retile and raise queued behind — an empty target Desktop cost exactly as much, because the price was never the windows that arrived but the apps that did not answer (#1037). The WindowServer census answers the one question a bulk pass needs before reading — is there anything here to change — in ~1 ms and cannot block, the same trade the boot prefilter (#662) and the adoption heal (#675) already made. An app tracking a window has a departure to remove or a verdict to re-check; one showing a window has an arrival to adopt; one doing neither has nothing the pass could change, and is skipped whole — never read partway, since the sweep that derives destroys from a live list must see all of it.

The price is a beat, taken deliberately. The switch notification can fire before the arriving window composites (the #1023 measurement), and a census taken then does not show it, so the pass at the notification may skip its app. The Desktop settle takes a fresh census and reconciles every app showing a window the loop does not track — the heal’s gate without the heal’s ledger, because quieting an id that failed to adopt is right for a permanent mismatch and wrong for a window whose app simply has not re-listed it yet. One switch, one such sweep, each app it reads showing a window and so not napping — a hung app still costs its timeout, once per settle. A follow onto a hidden Desktop keeps its own per-pid reap beside the sweep, for the window that composites after the sweep’s census and for a switch macOS accepted but never announced.

[Principle]

A reset clears app-generated state, never authored content or app-level preferences (#634). The two General ▸ Advanced hatches delete what KiwiDesk wrote for itself — gui.json, profiles, the arrangement snapshots — and always keep what the user made or chose: init.lua, the palette library, the display language, the login item, the onboarding flag. A panic button that can destroy user-authored work turns recovery into loss at exactly the moment trust is lowest; the boundary is what makes the button safe to press under stress. Corollaries that fall out of it, rather than separate rulings:

  • The label is “Reset All Settings…”, mirroring iOS (config wiped, content kept) — never “Total reset” or “Factory reset”, which over-promise the moment init.lua visibly survives.
  • Onboarding does not re-run: the person resetting is an existing user fixing a live problem, not a newcomer.
  • The deleted files go to the Trash, not a bespoke backup folder — one drag undoes a mistaken reset, and a dated-backup subsystem would be new state the “start fresh” action then fails to clean up.
  • The tier-1 Discard Saved Window Arrangement confirms nothing: it is strictly less consequential than the unconfirmed single-profile delete, because the snapshots regenerate from live state within one autosave cycle. The tier-2 wipe confirms every time, through its own dialog — never the staged-edit discard gate, which only fires while dirty.
  • Export Log… sits above the ladder as its rung zero (#1209): it changes nothing, so it is the first thing a user in trouble should do, and the bug report template sends them there before any hatch. It reads the log macOS already keeps for a chosen range — presets only, since a custom range asks the reporter for a number they cannot know, and the next-larger preset is a superset the maintainer trims for free.

[Trade-off]

  • Post-setup discovery (#331) closes the first-run discovery gap with the smallest durable surface that works: a shortcuts page among the tour’s own steps, listing the chord families that are bound — including the one that opens the shortcuts panel. It teaches the chords in the window rather than pointing at the menu bar, so it works with the menu bar auto-hidden, and it offers no button that opens the panel: the panel is an overlay and would land on top of the tour it was summoned from. Not a guided tour of every tab — that fights the contextual-help convention (#94) and is the classic skipped-onboarding trap. What the dedicated UserDefaults flag (onboarding.discoveryShown) decides is whether the app opens the tour by itself on a launch where nothing is wrong: permission granted but the tour closed before its closing beats, and the next launch reopens on that page; closing a tour that reached them records the dismissal, so no later quiet launch pitches it again. The flag is never the Accessibility trust state, which is the part that must not be undone — a TCC reset months later must not read as the app having forgotten the user. It does not gate the page inside a tour that is up for its own reason; gating it there was tried and undone, because it hid the shortcuts from everyone who had already finished once, which is the reader most likely to have reopened the tour for exactly that screen. So a revoked permission reopens the tour at its grant step and walks its screens again from there — a user whose window management has stopped is being helped, not pitched to. Copy is jargon-free for a first-run non-power user. (Supersedes #68 §5.9’s “separate follow-up pass” note.)

  • Configurable resize step (#58): the resize.step setting is authoritative only at authoring time — it sizes newly-authored Grow/Shrink bindings and is recovered from bindings on import, but changing it does not rewrite existing bound rows (their literal keeps firing). An in-GUI step control (a slider in Shortcuts ▸ Size & float) and a live-rewrite of already-bound rows are deliberately out of scope for now; the reserved slot is additive, so their later arrival won’t re-layout the section.

  • The keyboard preview shows one modifier at a time (#678 pass 5). The board opens on All — every key the shown layer’s bindings claim, lit in one colour — and a chip narrows it to a single modifier combination. It is deliberately not a multi-select overlay of several combinations at once, and the reason is that such an overlay must encode identity as HUE: one colour per combination, drawn as a stripe on the key. Hue is the channel colour-vision deficiency removes, so the number of combinations a board could show honestly was capped by how many colours clear the separation floor against the key they sit on — around four. A cap is not a detail there: it makes the panel’s own opening answer false for anyone with five combinations, since the board silently shows a subset under a heading that reads as the total. Single-select needs no hue at all (the fill says bound or free; rings carry the warnings), so the ceiling, the palette, the legend of colours and the accessibility residue all go with it — and it answers the question a user actually has while binding: if I hold ⌃⌥, what is left? Seeing two combinations at once was never the conflict signal; conflicts are per layer and KeybindingConflicts reports them separately.

  • And it shows one keybinding LAYER at a time (#1127), on the note the ruling above ends on: layers are alternate keymaps and only one of them fires, so a key claimed in another layer is no constraint on this one. The census already refuses to call that a conflict; a board folded over every layer contradicts that in ink, reading a key as claimed while you edit a layer where it is free — and the board’s whole question is is this key free HERE. Showing the other layers’ claims at reduced strength was weighed and refused: it answers a question (“taken elsewhere”) the user cannot act on while binding, and it buys that with a second visual language on a board whose two channels are already spoken for. What the scoping costs is that the picture now changes under a strip click, so the panel names the layer it is drawing — in the caption, and in the spoken description — whenever there is more than one to choose from. The two are one channel each, not the same fact twice: the caption is what a sighted reader has (the board cannot speak to them), and the board’s own description is what a VoiceOver reader has, since a picture must describe itself rather than lean on prose beside it. So the caption ANNOUNCES the layer-free sentence — the layer is drawn, not said twice in adjacent elements — which is the stand-down the fill legend beside it already takes.

  • Hovering a key answers “what is on it”, in the tally’s own slot — and there is no per-key keyboard route (#798). The board says which keys are taken and cannot say what takes them; the row list beside it can, but only by scrolling away from the picture. The strip replaces the tally line rather than adding a fifth line under a board that already carries four, because “what is taken” and “what is taken HERE” are one question at two grains. A popover was refused: anchored to a 19pt cap it covers the neighbouring caps, which are exactly what a reader asking “if I hold ⌃⌥, what is left?” is looking at. Reserved height comes from the sets the legend already gates on, so a user with no conflicts pays for no dead air.

    The keyboard obligation is discharged by the ROW LIST, not by a twin affordance, and that is the ruling worth keeping. The rule is no information is pointer-only, not every pointer affordance has a key twin — every action on a key is already on a focusable, searchable row one column over. Making ~60 caps focusable was refused outright: it is the per-mark reading the board’s one-element shape exists to replace, and 60 Tab stops in one panel fails the north-star’s first term before it reaches accessibility. A cursor moving inside one focusable board was refused too — VoiceOver owns the arrow keys once an element is focused, and the board cannot take focus at all on a Mac that has not turned keyboard navigation on, which is the default. What the change spends its accessibility budget on instead is the CONFLICT clause of the spoken sentence, which names the clashing actions and their cost: the one reading a screen-reader user cannot assemble from the board today. The stated loss is that a keyboard user cannot sweep the board to browse; browsing is the list’s job, and the picture’s job is the glance.

  • The board states two facts in two channels: the FILL says what your config has done with a key (bound, free), and a RING warns about it — dashed amber where macOS owns the key under the shown modifier and nothing of yours claims it, solid red where the key’s combo clashes. A third fill for “reserved” was tried and is wrong: macOS owns a key under a modifier, so blacking it out claims it is unavailable everywhere, when it is free under every other. The two rings differ by dash as well as by colour, because amber and red are both warm and hue alone collapses them for the viewers this rule exists for. Each ring’s colour is measured against the ONE fill it can ever meet — the dashed ring only rings an unbound key, a conflict ring only a bound one — which is what lets both clear the floor (KeyboardRingSeparationTests derives the pairs from the shipped tokens).

  • A bound reserved combo is conflict-class (owner ruling 2026-08-10, revising pass 5’s bound-wins reading). Binding ⌘W does not un-reserve it: the user’s row and macOS now contest one chord, which is a clash of exactly the kind two of the user’s own rows have with each other — and the earlier reading, where binding a key silently dropped its reserved ring, hid the warning on precisely the key that most needs it. So an overwrite takes the same solid red ring as an own-row collision, sharing one legend word (a second label would cost a translation round for a distinction the conflict banner already narrates row by row). The colour is forced as well as chosen: a warning that follows a key from free to bound moves from keyFree onto the accent, and amber fails the separation floor against the accent outright where the conflict red clears it — the same suite holds that pairing — so “keep the amber on bound keys” is not an available alternative. The legend obeys the caption rule: each ring’s entry exists only while its ring is actually drawn on the board — the red while a collision or an overwrite shows, the amber while a reserved key is still free under the shown combination. A chip being picked is not enough for the amber: a chip whose reserved keys are all bound draws red alone, and under All there is no single combination to check a reserved key against.

  • Measure colour with ColorVision, never a re-derivation of it. ColorVision.separation is Euclidean distance in simulated sRGB. A hand-rolled CIE-Lab proxy used during pass 5 reported warm colours at 17–25 against the accent green when the repo’s own measure puts them at 84–126, and that single wrong yardstick drove a redesign of every key fill — a neutral key, a bespoke lighter green, a generated hue ramp — before anyone ran the real function. The suites are the authority precisely because the numbers are unintuitive; a plausible re-implementation is not a second opinion, it is a different question.

© 2026 KiwiCanopy