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.)

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, and reserving use inside a business would charge the people who use it — 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” is retired (#1375), while “free to use” stays, since that is the grant’s own reach and not a pricing claim. 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 third-party notices (ACKNOWLEDGEMENTS) and opens both from About — BSL 1.1 wants the License displayed on every copy, and each MIT notice wants itself in every copy — and a trim of About’s links 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 level — SLSSetWindowLevel 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); an own-connection fast path was measured 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 once sat in Blocked by macOS (SIP) above; the rule that let them ship binds the next private surface that asks for the same exemption.

The C symbol that moved a window between Desktops is 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 an eager fold, 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).

A swap moves your window, and focus goes with it — across Spaces too; swap_here is the verb that keeps you where you are (#2509). swap is the one verb without _and_follow that may switch a Space, because what it moves is the focused window itself: a direction swap already keeps focus on that window as it changes slots, and an id swap to another Space or screen keeps the same promise rather than handing the focus to whatever arrived. The opposite intent — bring that window here, I stay — is its own verb, named for move_here, so neither verb answers a question the user did not ask. Two ids name no window as yours, so they move no focus beyond #1518’s rule that a window filed into the Space you are on takes it. A trade keeps both slots — index, track break and weights — and refuses a float, which has no slot, and a native full-screen window.

The pointer is not a second decision. On one screen 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.

A screen handoff is not a window follow, so the pointer crosses with the focus whatever the setting says (#2548). macOS keeps one active screen, and the pointer partly decides it: Spotlight opens on the pointer’s screen, and so does an app’s first window. A Space switch that moves focus to another screen while the pointer stays behind splits the Mac in two — you type on one screen and see the result on the other. So a switch that crosses screens moves the pointer onto the focused window there, or to the centre of that screen’s usable area when the Space is empty, and nothing moves when the pointer is already on that screen. The setting still governs every follow within a screen, which is the choice a user who turned it off made. The handoff is gated on the screen changing, never on which verb ran, so the rule-driven follow of a new window (#2478) crosses too. The pointer gestures stay out of it: the scroll step acts on the screen under the pointer, hover focus is made by the pointer, an unlock restores focus without moving it, and a held button is a gesture in progress.

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.

Two reasons, of different kinds — one technical, sharing the SIP entry’s root, one economic and unrelated to SIP:

  • 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 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: without Sparkle in the build, that ZIP is neither 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.

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.

Corollary: the gate is Sparkle-in-the-build, never a version number. The rule 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 (#904 records the confirmation). Read the paragraphs above as the argument for the gate, never as a description of a shut channel.

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.md ▸ Every 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, whenever someone builds one — this entry answers “why is there none yet”, so a future row supersedes it without contradicting it. What must not happen is unsetting the key and letting Sparkle ask again.

Installing updates automatically is a switch, and it never relaunches (#1542)

Section titled “Installing updates automatically is a switch, and it never relaunches (#1542)”

[Rationale]

“Install updates automatically” sits under General, below Start at login, and is off until the user turns it on. On, Sparkle downloads a found update in the background and installs it the next time KiwiDesk quits; it never quits or relaunches KiwiDesk on its own. Off applies from the next check: an update already downloaded and prepared still installs at the next quit. The user’s windows are the app’s whole job, and a window manager that restarts itself mid-task rearranges the screen under someone who asked for nothing.

Unlike the background check, this one is a choice, and one Settings asks only when the user goes looking, so there is no first-launch prompt to ask badly. What it trades: an automatic install never shows the update window, so a release’s “Before you update” is read after the update rather than before it, in “What’s new”. That is the bargain the switch offers, and it stays off by default for that reason. Sparkle takes the switch only while it checks on its own, so the row greys with that reason where the checks are off. Sparkle’s own alert used to offer the same choice as a checkbox; KiwiDesk’s update window has no such box, so without this row the choice would have left the app entirely. The value is Sparkle’s (SUAutomaticallyUpdate), stored per Mac and never in a profile or a backup, since whether this Mac updates itself is not part of a setup. After such an install “What’s new” is owed: it opens on the next launch the user starts, and a login launch leaves the status item’s mark instead.

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 a monochrome template. What that costs: 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 (SPUUIBasedUpdateDriver ▸ uiDriverDidShowUpdate, 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.

KiwiDesk draws its own update window (#1542)

Section titled “KiwiDesk draws its own update window (#1542)”

[Rationale]

From 2.0.0 a found update opens KiwiDesk’s own window rather than Sparkle’s: the offered version’s summary in one panel, then every change grouped as New, Improved, Fixed and Lua & CLI, each group with its count. Sparkle’s window renders the notes as one block of HTML, which is readable and answers neither question a reader arrives with (“what’s new?”, “was my bug fixed?”) at a glance — the grouping the release notes are written in only pays off where something groups on it.

The window covers everything since your version, not only the offered one. A user who skipped three releases is about to receive all of them, so the groups merge across the skipped versions, each entry labelled with its version and the counts covering all of them; the panel keeps the newest summary, and every skipped version’s “Before you update” line is shown, since a caution published two versions ago still applies to someone crossing it now. One version behind looks like any other offer.

One tab per group, not a disclosure each (#1666). The window first shipped every group as a disclosure beneath a row of per-type links that opened one and scrolled to it — two navigation mechanisms for one job, the second needing its own VoiceOver focus hand-off to work at all. A pinned strip of tabs is one mechanism, and puts every group one step away. Highlights is the tab that opens wherever there is an update, because the “Before you update” cautions live there and must be seen before Install; an empty group gets no tab, since a tab with nothing behind it promises nothing. Each tab keeps its count, which previews what changed before a click, and the window is 670 pt wide so that the four kinds and Next (below) fit in every shipped language; a kind this build does not know carries a title nobody measured, so the strip turns into a menu on the same selection wherever it would not fit. The window’s height is the tallest tab’s, so a switch never resizes it. The labels are words, not symbols (#1849): the window speaks the user’s language over notes that stay English, which is the ordinary split between an app and its content, and the symbols a strip of kinds would need misread here — sparkles as Apple Intelligence, an up-arrow as Install.

The window answers “you’re up to date” too (#1849). A check the user started that finds nothing newer opens the same window, headed “KiwiDesk is up to date”, on the same strip — the running version’s notes, then Next, which it opens on — with one Done that answers Sparkle. Two answers to one question used to look like two apps. Only that reason is ours: a check refused because the system is too old or too new keeps Sparkle’s own explanation, which says what to do about it. The window’s ground is the system glass, as the alert it replaced was, and only Reduce transparency stands it down — not the Liquid Glass switch, which governs KiwiDesk’s overlays on the desktop rather than a window.

What stays Sparkle’s: the check, the download and the install themselves — the window only draws them. A check the user started shows as the window’s own “Checking for updates…” with one Cancel, which the answer then replaces, so a check opens and ends in the same chrome. The window takes over at the offer and holds it through downloading, preparing and installing — one window per offer rather than an alert followed by a status window — because the notes stay readable while the download runs. A scheduled offer still never takes the screen (the reminder is unchanged); the row it leaves opens this window.

There is no Skip This Version. Skipping is the one choice a user cannot find again: Sparkle stops offering the version, and nothing in KiwiDesk says an update was waiting. Later costs a reminder; Skip costs every fix in that release for as long as the user does not think to check by hand.

“What’s new” follows an update whose notes nobody saw. After an automatic install, or the jump from 1.x (which has no window of its own), the first launch shows the same layout with one Done; after the window’s own Install the notes are not owed again, having been read there. It opens only on a launch the user started — a login launch is not someone at the keyboard, so there the status item carries the reminder’s mark and the quick menu a row. The notes are fetched from the same feed Sparkle reads rather than shipped inside the app, because they are written into the release after the build; offline, they stay owed for the next launch. The record of what was last run and read lives in the app’s own defaults rather than the config folder: it describes this Mac, and a backup restored elsewhere must neither replay nor swallow it.

After the window’s own Install, “What’s new” narrates the relaunch (#1667). The user has read the notes but has not seen the relaunch, in which windows move and re-tile with nothing on screen saying why. So the relaunched KiwiDesk re-opens the window, and while boot is still going through the open apps its header carries one line — the count the tour’s grant screen reads (the wait is narrated), in the same words, never a count of its own — which drops once boot is ready, leaving the notes. It opens whatever the launch looks like, since Sparkle starts it rather than the user, and still never over the permission tour. The install carries the notes it merged across the relaunch, because the fetch a normal “What’s new” waits on usually returns after boot has finished, and the line would then narrate nothing.

The window ends with what comes next, on a list that expires (#1813, #1849). Its last tab, “Next”, opens “Next on my list”: up to three items from ROADMAP.md, dated, with a link to Discord. It is the one place the notes look forward, and it is built so the forecast cannot outlive its truth — the failure a highlight must never risk. The list lives in a repo file the owner can correct on any day, never in a release body nothing corrects; the site serves it beside the update feed, on the domain installed copies already trust, so moving the file breaks none of them; and the window hides it once its date is sixty days old, so a list nobody keeps disappears instead of promising. The words fit a one-person project: “Next on my list” admits an item may not have started and the order may change, where “What’s next” reads as the next release’s contents. It is the strip’s last tab in every state (#1849) rather than a card under Highlights, because a card falls below the fold of a large release and a tab cannot; last so the kinds keep their order, labelled short so the strip still fits, its pane carrying the full heading. In What’s new and in the up-to-date answer the pane adds one quiet line, “Like my work? Support KiwiDesk on Ko-fi”; never in the offer, whose one decision is Install or Later, and the link goes with the pane when there is no list — Home keeps the permanent place to support KiwiDesk. The items stay English like the notes, and so does everything else inside a card — its heading, the date, the links (#1849): a card is the notes’ own page, and a translated heading over English lines read as two languages in one box. The strip, the title and the buttons around the cards speak the user’s language. Keeping it current is part of curating each release, because a list is only as honest as its last correction. On the relaunch after the window’s own Install the card is best effort: it shows what the offer fetched before the install began, since that relaunch opens before anything could be fetched again.

Any release’s notes stay one menu away (#2308). What’s new carries a release menu under its title, because the notes of an update are read once and wanted again later — a release’s highlights, after the next one replaced them on screen. It lists every release up to the running one whose structured notes read, newest first, each with its date and the running one marked; the untyped 1.x releases never list, since their notes cannot load, and a menu entry that opens nothing would promise what the window cannot keep — the window’s “All releases” link reaches them on the site. An update’s skipped range is one more entry, “Since 2.2.0”, and the opening choice only when the window opened for owed notes: every other opening, Home’s door included, is about the version running now. One release shows its own rows alone, untagged; a pick reopens the tabs on Highlights, since another release’s tabs differ, and the window is as tall as the tallest release so a pick never resizes it, the #1666 rule for tabs carried over. The offer has no menu: its one decision is Install or Later. Offline the window opens from the feed last read, kept in the app’s defaults beside the “What’s new” record and replaced by each read rather than added to, so it grows only with the feed’s structured notes; with nothing kept, the notes say they are online. Home’s window ends on “Next on my list” too, from the list last read, kept the same way: a read that fails keeps it, a section the owner emptied clears it, and the list’s own sixty days retire one nobody refreshed — so a correction reaches Home’s window on the open after its next online read, while an offline open never shows a list older than that limit. Home’s What’s new answers nothing — owed notes keep their own window and their Done, and an offer on screen comes forward instead, since it carries the notes worth reading.

What a 1.x client sees is unchanged. The feed keeps its HTML description beside the structured notes, so a copy that predates this window keeps Sparkle’s; the window first appears for the update after 2.0.0.

The Highlights gold marks, never inks (#2038). The panel’s edge, its ★ and the spotlight rows’ symbols are gold; no text is. So its separation from the washed card is measured with ColorVision rather than held to a luminance ratio: the house floors no card boundary, and the text beside every mark carries the meaning. The relaxed floor holds only while the gold colours no text.

A fixes-only patch still opens the window (#2038). With no spotlight and no caution, Apple’s own updates leave only a mark; this window opens as it always has — the prose and the Fixed tab — because the reader who reported a bug is the one who should see it fixed, and a mark tells them nothing.

What’s new leads with a spotlight (#2038)

Section titled “What’s new leads with a spotlight (#2038)”

[Rationale]

The Highlights tab carries EITHER the prose summary OR one intro sentence and spotlight rows, decided per release — never both. Two paragraphs of prose answer “what is new” only to someone who reads them; a row — an SF Symbol, a two-to-four-word title, one line of what the reader gains — is read at a glance, which is the shape of Apple’s own What’s New sheet. The rows replace the prose rather than sitting above it, so the window keeps its height: it is already sized to its tallest tab. Every row’s change stays a bullet in its New or Improved tab — the row is the signpost, the tab the record — so nothing exists only as a row. Rows are the default for any release that adds something, a patch included; prose is for a fixes-only release, which has nothing new to find, and the owner may overrule either for a release.

The row count and a row’s line length are guidelines, not limits (owner ruling 2026-10-17). Six one-line rows fill the notes at the window’s tallest; past that the notes scroll, which the window already does for every tab, and changelog-sync warns at curation rather than refusing. A release with more worth finding than six rows should not hide it to fit a number, and a refusal would push the curator to drop a real signpost or cram two into one line. The line guideline matters more: a line past about seventy characters wraps to two, and two-line rows are what held the old cap at four. Rows merged in from skipped versions are not trimmed either: kept short, they scroll like the rest.

A row’s symbol depicts the object, never a state or an action. Sparkles read as Apple Intelligence and an up-arrow as Install — the same misreadings that kept symbols off the tab strip. A name the running system does not know draws no icon rather than a placeholder.

“Show me ›” exists only where the running build can land on the setting, and is dropped — not greyed — where it cannot. Greying is how Settings says “this is here, just not now”; a link to a setting this build no longer has (renamed, moved) points at nothing ever, so a greyed one is a promise with no way to keep it. The census id is checked twice for that reason: at publication against the tree, and at runtime against the build. Behaviour you notice without configuring anything gets a row with no link, and a Lua-only change gets no row: the window speaks to everyone who updated, and the rows to the Settings they can open.

“Show me” hides What’s new and Settings carries the way back. Leaving What’s new open behind Settings fails on this app: it is an own floating window, so the float-tier raise either lifts it back over the control just landed on or leaves it buried under the tiles. So What’s new hides without being answered, Settings lands on the control the way a search pick does, and a banner across the Settings shell offers the next linked row, the way back, and a dismissal. It survives navigation inside Settings and has no timeout, because a reader exploring a setting is not done with the notes; closing Settings brings What’s new back rather than answering it silently, since only the reader’s own Done or × says the notes were read. Nothing on that path writes: the rows are signposts, and a control inside the notes would be a third write path beside the draft and Lua, on a launch where boot may still be re-tiling. No animation is per release either — the notes are written after the build.

Across skipped versions, rows win. A reader crossing several releases sees rows when any of them has rows, newest version’s first, every row of every version; a row from an older version carries its version after the title. The intro is always the newest version’s own: its intro sentence, or — for a patch told in prose — that prose in full, which is why a patch’s prose is capped at a couple of sentences while a minor’s is not. The exception is a minor or major with no rows: its prose stands alone and older rows are dropped, since a minor’s prose is written to be read whole and is never cut to an intro. That edge is rare by construction — the curator gives a minor rows — and accepted. Every covered version’s caution stays, as before.

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. 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.

Sparkle shows 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 (owner ruling). The whole block should read in one screen of the update window.
  • 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 (owner ruling): 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.

The sections are typed: New, Improved, Fixed, Lua & CLI (#1542). This reverses the earlier ruling that a release names its own sections. That ruling’s argument was that fixed buckets split one story three ways: 0.9.7’s multi-screen Desktops was at once new, improved and a fix. It lost to how the notes are READ. A reader comes with a question, “what’s new?” or “was the bug that annoyed me fixed?”, and a title of the release’s own choosing answers neither at a glance. Three consequences:

  • Each change goes under one type, the one that fits best. A change that is both new and a fix goes under New, and its wording says what it also fixes. A fix that also improves something goes under Improved. The story a type splits is told in the summary, which stays prose.
  • Lua & CLI holds only what exists for scripting: new, renamed or retired verbs and setters, and changed output. A feature people use through Settings goes under New even when it also has a Lua side, so nothing is written twice. A scripting break opens that section, addressed to the people it reaches (“If you write your own Lua config: …”), rather than alarming everyone above the fold.
  • “Before you update” is for something everyone who updates must do or will notice at once: one closing paragraph of the summary, and usually absent. It never warns about going back: a downgrade limit (“the older version can’t open your profiles”) alarms every reader for the few who would ever downgrade, so it belongs in the full release notes, if anywhere. Say what the reader keeps rather than what they risk: “Your settings carry over on their own” (owner, 2026-09-24).

Releases before 2.0.0 keep the free titles they were published with.

This binds whichever surface carries the notes: the GitHub release body is the source, and the changelog page (#873) and the update window (#874, KiwiDesk’s own from 2.0 — #1542) are generated from its curated block — scripts/changelog-sync and scripts/appcast-sync — so they inherit it 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. “The last beta before 1.0” (struck from 0.9.7’s draft) breaks 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.

What comes next has one home, and it is not the release body: a dated list that expires, kept in a file that can be corrected the day plans change.

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 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 work whose subject is this release’s own making — translating sentences this release introduced, re-vendoring a font, wiring the release pipeline: real work, and none of it 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: with the names in a Swift table that “can never drift from the real API” and the signatures in 4,800 lines of hand-written Markdown that could, and did. list_commands 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.

kiwidesk mcp is hand-rolled, and holds no state

Section titled “kiwidesk mcp is hand-rolled, and holds no state”

[Rationale]

The MCP server is a few hundred lines of JSON-RPC in Sources/KiwiDesk/MCP/, not a Swift MCP SDK. The CLI and the app are one executable, so an SDK would ship inside the signed, notarized .app with its own Package.resolved pins, each owing a verbatim notice in ACKNOWLEDGEMENTS (LicensePackagingTests), and at the owner’s ruling (2026-10-14, #1685) no Swift SDK spoke the current revision, 2026-07-28. A tools-only server needs the parse, the two handshakes and tools/list / tools/call, which is less than the dependency’s packaging costs. Whether that still holds is re-checked on each spec revision (#2257).

It holds no KiwiDesk state: list_commands and help answer from APIReference, compiled into the same binary (the entry above), and every other tool forwards one request to the running app over its socket, a connection per call so a relaunched app is reached again. A new tool is backed by a Core verb with an APIRecord, so the CLI and Lua get it too; a tool that computed a verdict beside Core would be its second home. Nothing but JSON-RPC lines may reach stdout, and the window manager never listens on HTTP: an AI app that reaches only remote servers goes through its own tunnel.

A command that cannot be undone has its own MCP tool

Section titled “A command that cannot be undone has its own MCP tool”

[Principle]

APIReference.destructive registers the commands that lose something a user cannot get back by running another command — a file, a window’s content, unsaved live changes. run and run_batch refuse them and run_destructive takes only them, carrying MCP’s destructiveHint: annotations are per tool, so the split is the only way a client can tell the two apart. A confirm argument on run would confirm nothing, since the model fills it in itself. save_profile, load_profile and bind_profile_to_desktop belong in the set though they read as everyday verbs: one overwrites a saved profile, the others replace unsaved live changes. A name the binary does not know is refused rather than forwarded, so nothing reaches the app past the check. A batch is judged whole before anything runs, so it never stops halfway for a reason it knew up front. The trade: a hint is honoured by the client, and an app that ignores it runs the command unasked. look.save refuses a name already taken where Settings offers Overwrite: an overwrite loses the saved look and would put every save behind run_destructive, so the verb stays one run can send, and replacing a look is the destructive look.delete followed by a save.

A window’s frame is reported from KiwiDesk’s own placement

Section titled “A window’s frame is reported from KiwiDesk’s own placement”

[Rationale]

list_windows (#2492) answers from what KiwiDesk already holds — a tile’s placed frame, a float’s target while it moves and its last report after — and asks no app, since a query that messaged every app would wait on the slowest one. A frame parked out of sight, on a Space no screen shows or behind a monocle (#881), reports none: a corner position would win every “nearest window” search a script runs. Whether a window is in view is the App Bar’s own verdict, read through the one placement both take, so on Monocle and Scrolling the bar and the CLI agree; the other layouts have no bar, and there a tile another tile covers — a pile — reads covered unless it is in front, since KiwiDesk keeps no order for the rest. Coordinates are top-left with y growing down, as AX, yabai and Hammerspoon report them, and list_monitors gives each screen’s origin in the same space. The verb takes no window argument: a window-taking verb left without one means the focused window here (#1518), so a bare call would answer one window; a script picks one with jq. The trade: an app that refuses its size shows the frame the layout asked for, and a float an app moved itself shows its last report.

The stacking rank z (#2546) is the one field read from the WindowServer, its public front-to-back list of on-screen windows, once per call. KiwiDesk’s own order cannot give it: it knows no order inside a pile or among floats, nor an app raising itself. The App Bar’s rule that a bar takes issued frames, never a compositor read, does not reach this verb: that rule exists because the bars refresh on every switch (#1925), while list_windows runs only when asked. The rank counts the tracked windows on the screen a window’s display names, so a menu or another app’s popup covers without counting, and the list keeps its Space order rather than turning into the stacking order, since the rank is per screen and the list spans every screen. visibility stays the App Bar’s verdict over KiwiDesk’s own order, so the two fields can disagree where an app raised a window by itself; the rank never feeds the verdict, or the CLI and the bar would part on Monocle and Scrolling.

Whether a setting lasts is not a fact about its command

Section titled “Whether a setting lasts is not a fact about its command”

[Rationale]

No set_* verb but set_default_profile writes a file, so whether a value outlives a profile load, a reload or a relaunch depends on where the value lives — a profile, the shared look, gui.json’s app-wide settings, a Space’s session — and on what is saved afterwards, never on the command. A persists field on APIRecord would be false for the setters it seemed to describe. kiwidesk mcp tells the AI app what a command changed instead, and making it permanent is a separate write — the keep tool — never a property of the setter, and never save_profile, which snapshots everything live.

What a command changed is a diff of the values Core reports taken around it, never a list of setters: a list is a second register of what each verb writes, and the set_* spelling it was read from already named a verb that writes a file. Whether a change is still pending is Core’s verdict too — its live value against what its owner stores, asked again on every request — so a Settings Save, a value set back or a keep is seen however it happened, and a guess from the session’s own history would call a stored value unkept. The MCP server keeps only what Core reported of the leaves the session touched, never a verdict of its own; a leaf now stored with the value the session set was kept, one stored with another value was replaced. Because the judgement compares values, it needs no epoch: a load leaves every leaf either stored or holding another value, and a keep sent after one writes nothing back, since what it would write is already stored. A keep writes each leaf as live at that moment through the same door the bar menus and the tour use, so an open Settings draft takes it alike; it lands the shared look without repainting the live one, which still holds the session’s other pending look values; and it answers what it re-reads as stored, never what it meant to write. look.apply is a setter in this sense and paints live only; the tour’s click writes through because the tour has no keep (▸ The tour’s look is written through, and the tour owns its undo).

A kept app rule lands where Settings ▸ App Rules would put it: a rule the live profile already holds keeps the reach it has, and a new one goes into the shared rules every profile without its own follows (▸ the Applies to checklist; #1685 ▸ Ruling 6c). It takes that page’s own door rather than the live-profile one, since a shared rule’s reach spans files the live-profile door cannot write; an open Settings draft still takes it. Writing a new rule into the live profile alone would make an AI app’s rules quietly profile-local while the page’s are shared, and a user switching profiles would find the rule gone. The keep answers every profile whose rule it changed, so the AI app can say so.

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: 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 — a reader believes it first, so before/after art drawing the “before” as scattered rectangles ships the retired claim however the cards are lettered. Both frames 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.md ▸ Solving macOS Papercuts writes them for people who already know “monocle”, “spaces” and focus_or_spawn. Simple mode gets the symptom and the relief, never the mechanism — and never a claim the app does not make: 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 is 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 says what it spares — its caption names init.lua and the colour palettes, which 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.

init.lua is the one file a restore takes only when asked, on both ends and off by default (#2517). It is code, and a restore runs it at once; a backup is a file that can come from someone else, so carrying or running it is a choice the user makes at the moment of the act, never a default a stranger’s file inherits. Asked, it is replaced like everything else and the old one goes to the Trash. A restore that replaces it is judged on the incoming file: the destination ends up holding the source’s setup whole, so the “settings over a Lua-owned config” refusal, which exists to stop a half-landed restore, has nothing to refuse there.

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.

Compare and Copy keeps one setup, from a sheet that commits

Section titled “Compare and Copy keeps one setup, from a sheet that commits”

[Principle] (#2324 ruling 14)

Sync across Macs copies a whole setup between this Mac and a folder the user’s sync app shares — one direction at a time, the direction the user picks after seeing both sides. It is the restore above, run against a folder instead of a backup file, and it inherits that entry’s answer: the side being replaced goes to the Trash, and nothing merges. A merge would make the result depend on what both Macs happened to hold, which is the thing a person syncing two Macs wants to stop thinking about.

The choice is made in a SHEET that commits, a ruled exception to “a sheet is read-only”. An alert cannot show two dated sides next to each other, and the choice is only safe when it is made looking at both — which side changed last, and on which Mac. The sheet still takes no model: its two Keep buttons run through closures the row hands it. Return cancels, since every Keep replaces something. A folder holding no setup yet is no comparison: one side exists, so it takes one alert — copy this Mac’s setup into the folder, Return copying — since the sheet’s only claim to commit is showing two dated sides next to each other.

Unsaved Settings edits fold into that same sheet rather than stacking a dialog on it, which #1619’s one-dialog rule asks for. Keep This Mac’s becomes Save and Keep This Mac’s and saves through the one primary Save first; Keep the Folder’s says the edits are replaced, the way the restore’s confirm names the draft it drops. There is no third button that copies the saved setup and keeps the edits on screen: afterwards the folder would differ from what Settings shows, which is the confusion the feature exists to end.

The folder is remembered on this Mac and never written into it, and Desktop bindings stay this Mac’s (ruling 12): Keep the Folder’s carries this Mac’s bindings across the replace. A folder a newer KiwiDesk wrote refuses both directions, so an older Mac never writes over a setup it cannot read (ruling 2).

Trade-off: a copy is whole. Someone who wants one profile from the other Mac takes the whole setup or exports that profile by hand. Accepted for the same reason the restore accepts it.

Following a sync folder mirrors it, one side’s change at a time

Section titled “Following a sync folder mirrors it, one side’s change at a time”

[Principle] (#2324 rulings 4, 15; slice 3)

Following a folder never moves the config folder: it stays in ~/.config/KiwiDesk, and the folder is kept in step with it, file by file. A relocated config would make a missing folder — iCloud signed out, a disk unplugged — a missing setup at launch, and turning following on or off would need a relaunch. A mirror pauses instead, and Compare and Copy already copies whole setups in both directions.

What crosses is decided by content, never by date: each synced file’s hash is recorded once both sides hold it alike, so a side still holding the agreed version did not change and the other side’s change is the one to copy. A sync app rewrites dates; it cannot fake a hash, and our own copies leave both sides alike, so nothing echoes. A file both sides changed since they last agreed stops the whole pass: no rule picks the newer side safely when two Macs were offline, so the user picks one with Compare and Copy, which is why following a folder that already holds another setup opens that sheet with Keep the Folder’s as its default.

A change that came in enters through one door that writes no synced file: the shortcut top-ups, a gone Space’s shortcut drop and the Space-list mirror stand down inside it, since the Mac that made the change already did its own bookkeeping, and an answer here would echo there. The top-ups’ “already offered” marks travel in gui.json for the same reason — a row deleted on one Mac is never re-added by another. Reset All Settings stops following: a reset is this Mac’s, and following would empty the folder every other Mac reads.

Unsaved Settings edits meet an arriving change per setting (ruling 4): a setting only the other Mac changed is taken, one only the draft changed stays, and one both changed waits for Save. Save then shows every such setting in ONE sheet (#2450), not an alert per setting: an alert per setting would ask four times for one nudge of the Outer gap, and a list the user sees whole is easier to answer than a run of questions. Each row starts with neither Yours nor Theirs picked, and Save stays dimmed beside an amber warning band until every row has a side, so Return does nothing until then and Escape cancels: a conflict is two Macs disagreeing, and a default — or an accept-all control — would let one side win unread. Theirs on a row whose draft edges agree moves the whole group, since a merge of one Mac’s top edge with the other’s left is a setup neither had; a side whose edges differ lists them, so a pick shows what it overwrites. A line under the title counts what took no question, so the settings missing from the list do not read as lost. The sheet commits through closures its host hands it, the ruled exception to a read-only sheet that Compare and Copy also takes. The automatic switch is its own row under the folder (ruling 15): “no folder” and “folder kept, manual” are different states and get different controls.

Trade-off: a Save writes several files, and the other Mac may apply the first before the second has landed; it catches up at the next pass, a second later.

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. 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. 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.

Worth more than the guard would have been: the two adjacent classes are 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 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. Freezing it too — bind_profile_to_native_space keeping its name “since native already disambiguates it” — was a pre-release cost call, when no migration and no broken init.lua was the whole argument. It was lifted 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 (#765 carries the count for the alternative). 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 harder to see 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 was the whole defect in one pane: its caption said a profile was “remembered per display arrangement”, the preset outline below it labelled a screen “Main screen”, and the Home card that opened the placement picture was called “Monitors” — three words for one thing, in one glance. That was the evidence the ruling was taken on.

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.

A vocabulary ruling and its sweep are two decisions, ruling first. Deciding the winner costs a paragraph and makes every string authored afterwards correct; sweeping the existing ones reaches the settings census, the search index, the site corpus and docs/. Ruling first leaves the corpus knowingly inconsistent rather than accidentally so, which is the cheaper of the two states and the only one that converges. For this noun the sweep is #865: the English copy, the page now called Screens, and the user-facing docs.

The wire stays, by ruling (owner, 2026-10-09). A Lua verb, an event name or a profile key spelling display or monitor keeps it, and the set is derived rather than listed (grep -E 'display|monitor' docs/lua-reference.md docs/cli.md answers it, and answers it again after the next verb lands). Copy is read and forgotten, so changing it costs only the strings; a wire name is typed into a user’s init.lua and stored in their profiles, so a rename is a breaking change for every config that spells it and, for a stored key, a ConfigMigration crossing. A synonym is not worth that. A source identifier follows the page it names (.claude/rules/gui.md ▸ File layout) and stops at the same line: a stored or spelled identity keeps its spelling.

A retired noun stays findable. People still arrive typing monitor or display, so the Settings search answers both through SettingsSearchSynonyms — the alternate-vocabulary home built for exactly this — rather than through a label that would reintroduce the split.

A noun swap keeps its translations. A value whose noun moves from one synonym to another still names the same object for the same purpose, so its translation is not stale — it names that object in its own catalog’s word, which is the next paragraph’s business. scripts/drop-key is for a changed meaning; using it for a noun swap would replace every value the sweep changed, in every catalog, with English until a retranslation landed.

What the sweep left alone, as Family C requires a sweep to list:

keywhy it keeps its word
general.language.display“Display language” — the verb sense, not the device
desktops.helpquotes Apple’s “Displays have separate Spaces” verbatim
colors.liquid_glass.reduce_transparency.helpquotes Apple’s path, Accessibility ▸ Display ▸ Reduce transparency
presets.coder_and_monitor.name“Coder & Monitor” is a preset’s proper name
a KeyBinding row’s stored label (Toggle display sticky, Make display sticky)a stored identity in the user’s config, resolved to the drawn keybinding.*_display_sticky labels, which say screen

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 slots, which sees only the shown windows of the scrolling line; along that line, focus and swap step in drawn order (runStep, #2542)where a split cannot hold two windows at min-size, the trailing chain of same-direction splits scrolls along the last split (BspLayout+Run, #2542); a whole-space cascade only where not even one window fits at min-size
StackgeometricNavigation.neighbor over slots, which sees only a zone’s shown windows; along a zone that hides windows, focus and swap step in drawn order (runStep, #2235)a zone that overflows scrolls (ZoneRun, #2235); the emergency pile — a zone that cannot hold one window at min-size, or two zones that cannot coexist — cascades downward, whatever the arrangement (#222)
GridgeometricNavigation.neighbor over slots, which sees only the shown windows of the last track; along that track, focus and swap step in drawn order (runStep, #2541)the last track scrolls the windows past the cells (GridLayout+Run, #2541); a whole-grid cascade only where not even one cell fits 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 (monocleCycle) — same 1-D shape as scrollingno — every window shares one frame
Trackarray-ordersteps both axes (trackStep)surplus tracks merge into one far-edge overflow track (#192), and every track that overflows scrolls (ZoneRun, #2235); only a track that cannot hold one window at min-size, or tracks that cannot keep it side by side, pile (OverlapStack)
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 otherwise is — dropped from effectiveTiledMembers, it can 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.

A KiwiVision lens that keeps focus on its apps adds one more filter, orthogonal to both models like the float tier (#2103): navigate hands every model the one predicate lensKeySkip, which refuses every window the lens leaves out, judged per window by the lens’s own verdict the plates take. The geometric search and the float tier drop them from their candidates, Scrolling and Monocle step past their indices through Navigation.arrayStep, and Track folds the predicate into the skip its pile handling already takes, its wraps through the same arrayStep — an across step judging each track by the window at the focus’s position in it, as it does a pile. The scroll gesture’s steps skip too, through navigate on a row and its own-order step everywhere else. A new layout’s navigation takes that predicate too, or the lens stops steering the keys on it (A lens steers the keys, never a pick).

A tie goes back to where the last step came from, else to the first in window order (#2619). The geometric score is forward distance plus twice the lateral offset, so where a full-height master faces a stack of three, every candidate lies at one distance and the lateral term alone picks the middle window — an accident of the score that nobody can read off the screen. So a tie has a definition, and only two shapes meet it: the origin’s cross span wholly contains two or more of the nearest candidates, or two or more share the best score. A short origin or a partial straddle keeps the score’s pick, since its position already says which window is meant.

A focus tie returns to the window focused just before the origin when that window is among the tied ones, so stack → master → back lands where it left; otherwise it takes the first tied window in the Space’s flat order. The history is one step and no further: a deeper or timed recency picks by state nobody can see once a while has passed, which is the very defect of the score’s middle pick. It takes the first in order rather than the one nearest the origin’s number, so a tie lands on the same window from either side. A swap takes the first in order alone: a swap moves a window, and letting a focus history the user cannot see choose which window moves makes the same key displace different windows.

Track’s step across tracks still picks the track by array order, but the window it lands on there is the shown one nearest by centre on the cross axis, with the same tie rule (Navigation.beside) — never a window at the same position in the track, which could be one a run had scrolled away. Under a lens, that step passes over a track only when the lens skips every shown window in it. A hop onto another screen keeps the step back and nothing else, another Space’s order meaning nothing from here; the float tier and a Floating Space keep the plain score, a hand-placed float’s order meaning nothing either.

The step back reads the honored-focus record, which a focus REPORT writes: two presses faster than the first one’s report, or a focus whose report was distrusted, leave the step before them as the last one. The one-step reading accepts that residue rather than keep a second, command-side history.

One setting decides what a focus key does at its last window, in every layout: Off, This screen or All screens (#2493). Three per-layout switches answered the question for the array-order layouts only, and the geometric ones stopped at the edge with no way to wrap; a user who moved between layouts met three answers to one key. Off stays because it keeps the #436 wall a held key parks on, and a script detects an edge by the failure. This screen wraps within the screen — a Floating Space stops at its last window: the array-order layouts to the far end of their order, the geometric ones to the window nearest the opposite edge in the focused window’s row or column, found by Navigation.neighbor seeded from a point just outside that edge — never a second search. All screens, the default, continues to the nearest screen that way showing a window, and past the last screen to the farthest on the other side, which may be this screen, so with one screen it is This screen. A screen counts only where it overlaps the focused window’s band, so a diagonal screen is never a neighbour; the landing is the window nearest the entering edge, and along a Scrolling or Monocle row’s axis the end of its order on that side. The edge is reached only past each layout’s own steps, so a Scrolling row, a Monocle cycle and a Stack or Track run that hides windows are walked to their end before focus leaves the screen. A geometric wrap that lands back in the run it left goes on to that run’s far end, a hidden window included, as a Scrolling row’s wrap reaches its far end (#2618); one that lands outside the run keeps its target. The scroll gesture acts on the Space under the pointer and never leaves its screen. The setting is the Space history’s shape — a gui.json base with a sparse per-profile override — because a profile may want the screen wall its arrangement was made around, and no layout wants a different answer than another on the same keys. The Space step reads the same setting (#2592): any value but Off wraps it round its own screen’s Spaces, so one row answers what every key does at its end.

The three switches cross to the default, All screens, whatever they stored, and a one-shot step drops them from every file (owner ruling 2026-10-23). A stored false cannot say whether anyone chose it: before 3.2.0 it was the default, 3.2.0 kept a stored value, and every later build re-stamped the file’s format, so the format cannot date the choice either. The cost is the user who turned focus wrapping off on purpose: it comes back on, and Off is one row away under Shortcuts & Gestures ▸ Focus.

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. 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 reads raw lastFocused because its items are spaces (#414).

No layout exempts a sticky window from its overflow: a scrolled run — a Stack zone, a Track track (#2235) or a Grid’s last track (#2541) — hides it like any other member, and a whole-region cascade (the emergency min-size fallback) has no fully-tiled slot to give it (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.

A Stack zone’s or Track track’s scrolled run (#2235) exempts nothing: a sticky window scrolls and hides like any other member, and its sticky mark stands down while it is hidden. The exemption exists because a pile reduces its members to title-bar slivers; a run reduces nothing, since every hidden window is one focus step away. Holding a sticky shown would take a slot the focus walks through, so a run could no longer show what the focus asked for.

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.

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). Handing the focused slot to every window created makes a popup that surfaces as an AX window — a Telegram context menu — space.focused on arrival, and its dismissal then reads 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 span (#376), so an overlay can neither split a run nor reserve a capped slot the bar then draws nothing in. The App Bar’s tiled row 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) — takes the built-in ignore gate (#448) rather than draw-time suppression: #300 kept those bars managed because only the ring was wrong, but a managed bar is also space-pinned — tiled, stashed, and dragged across space switches (#446). They are 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. A raised window of an accessory app that has a title-bar button is admitted as one of its app’s normal windows (#2072, the macos_join_network dump), which is the escape’s weak point: a launcher that lists buttons anyway reads as a window.

So the escape asks the subrole too: a buttoned AXSystemDialog takes none (#2239). Spotlight’s panel on macOS 27 (com.apple.campo, #2236) lists all three buttons on that subrole, and managed as a float it was moved off its own position by the float fit. A bundle id cannot carry that verdict: the process that draws a panel is not stable across releases (macOS 27 moved Spotlight’s panel out of com.apple.Spotlight into Siri’s com.apple.campo), and it names one launcher where the next buttoned one would slip through. The subrole is the launcher’s own evidence. Every other corpus window with it is buttonless, non-accessory or ignored by name, so the narrowing changed no recorded row (AXDumpCorpusTests, the kiwidesk/macos27_spotlight row). The bundle belt stays for a launcher that shows a Dock icon, which the accessory branch never sees.

Above that class sits the screen-saver level (#2069): a window at CG layer 1000 or above is ignored whatever its app and whatever its buttons — a dictation HUD, a screen-share overlay drawn over the whole display. Nothing at that level is a window to arrange, and the level is the one signal strong enough to move a default toward ignore (#1810): every corpus window there is a HUD or an overlay. The trade is that a window an app lifts there cannot be managed and has no per-window way back, since ignore_rules only adds. The level refuses adoption, never a window already tracked that rises to it — that removal would read as a close, raise a neighbour and lose the slot.

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 part of outwardReach (#1378): a hand-set gap may still let the bloom bleed, but Fit is the one action that leaves no gap beyond the stroke, so under Fit a bloom outside the reach lands entirely on the neighbour’s content and its unfocused ring. Fit grows by the full resolved blur, once, on the focused side (inner = reach + blur + (unfocused ? reach : 0) + extra) — full rather than half because the boost layer’s radius is half the margin, so at half the neighbour’s edge sits inside the bright part, and the overlay frame already clips the halo one blur past the stroke, a distance #533’s device QA accepted; once because only one side of an inner gap is focused. No third fit choice: a decision the arithmetic can make is not moved onto the user, and it would need its own gate arm. Glow off leaves Fit byte-identical; a float keeps the same reach off bars and screen edges, a bloom clipped by a bar being the same blemish as a clipped ring (FitGapsGlowTests).

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. The bloom is a CAShapeLayer double shadow (a full-radius pass plus a half-radius boost, summing toward the full glow colour at the ring edge), the one renderer that blooms correctly: a 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 band on device. Its interior is cut out, so a front-order ring blooms only outward. Where the ring draws is the rendering ruling below. 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.

A presentation in front stands the shelf down the same way (#1787). A window filling its whole screen — the float-region entry’s verdict — is a slide show or a borderless-fullscreen game, and a bar painted over it is the defect native fullscreen already rules out, so both bars stand down on a screen whose FRONT window fills it, through the one shelfStandsDown(on:). Front and not focused, because PowerPoint’s presenter view keeps the focus on one screen while the show fills the other; a window is on the screen its midpoint is on, since the presenter view overscans a point into its neighbour. The WindowServer’s order is read only while a tracked window fills that screen, so a desk without one pays nothing, and a move or resize crossing the verdict re-reads it, since an app may animate its show open past the focus report. Layout still reserves the strip — the reservation is the config’s — so nothing tiled reflows as a show starts or ends. Residue, accepted: the order is re-read at an honored focus, a retile or a crossing, so a window of an app KiwiDesk ignores stepping in front of a show leaves the shelf down until the next of those, and a screen-filling window KiwiDesk does not track keeps the shelf drawn over it.

“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 draws on the public AppKit panel and tracks through WindowServer opportunistically (#285, #1917): when the runtime-linked SkyLight event surface resolves, the ring follows WindowServer move/resize/order events directly, and without it the AX echoes drive it. Drawing never takes the private path, for two reasons: a SkyLight context cannot draw the glow’s coloured bloom (#533, the glow entry above), and on macOS 27 a raw SkyLight window stays composited over the Mission Control overview at its desktop frame whatever Space it is pinned to, where the panel’s .transient hides it with the desktop (#1917). A front-order panel takes its target’s window level, so a raised target’s band never covers the ring. Re-stacking the panel is AppKit’s relative order, a synchronous WindowServer answer that stalls a Space switch under GPU load, so only a ruled trigger re-stacks a ring (#1925): WindowServer applies no SkyLight order to an AppKit panel, and the transaction #1925 first used for it moved nothing (#1962). Moving the panel is the ring’s one private WRITE (#1956). 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): 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): every track surface — the cap, new_window, move_to_track / track.swap and their shortcuts — is always visible and always works. Putting them behind a global set_track_advanced switch, default off, with the shortcut rows inert and hidden until it flipped (#181) was rejected: 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 header caption on Layout Defaults ▸ Track marks it a more advanced layout, and the shortcut rows say which layout they belong to. The obligation the copy carries is not “Track has a caption” — every layout card has one (#678) — 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.

The shortcut half of that copy lives in the rows’ own drawer — Shortcuts & Gestures ▸ Move windows ▸ Move windows in the track layout, the #1125 door shape, with a ? saying what previous and next mean in a track (#1440). 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. The title carries the sentence, so no caption restates it inside the drawer (owner ruling).

The overflow track is read-time, not stored (#192): 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. An “overflow-aware spawn” — shifting windows into a new track at spawn based on available space — is rejected for putting geometry into state (it would make spawn outcomes monitor-dependent and non-deterministic). The focused_track default relaxes that deliberately — see below.

The Track limit counts the overflow track (#1354, owner ruling). [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. Counting NORMAL tracks with the overflow beside them draws four columns for a typed 3 — 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” is rejected for the same reason: it would make 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). 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 read 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 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): focused_track — the default (own_track is 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, 2026-08-31). The deliberate-refocus case that consumption would protect has real discriminators: 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 — bounded, where an un-aged ledger poisoned the window permanently.

Three corollaries (#687, #887 device QA). 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 sits unconsumed forever and classifies 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): 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 hands 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.

An own window the GUI just brought forward is honored by presence, not order (#1861). The front is KiwiDesk’s own intent, so the window’s focus report is never read as a z-order echo, whichever raise stamped the window last. Order cannot serve here: when one own window replaces another, the closing window’s destroy reaches Core after the front, and the close-return restack’s stamp is the newer of the two. The intent lasts the echo window’s ~1 s and ends early once focus is honored on a tracked window of another app (an untracked one leaves it to age out), since the user going elsewhere makes a later echo of that window a raise again. The accepted cost: inside that second, a re-raise of the fronted window while the user moves between KiwiDesk’s own windows is honored rather than reverted.

Windows KiwiDesk moves on its own wait for your hand to rest (#804)

Section titled “Windows KiwiDesk moves on its own wait for your hand to rest (#804)”

[Rationale]

A click is aimed 100–300 ms before the finger lands. A retile that moves a window inside that window of time delivers the press and release to whatever now sits under the pointer, and the sharpest case needs no second window: a resize slides a different control of the same window under the cursor. So motion KiwiDesk makes on its own — an app’s own event, boot, a display change, the wake replay, a command over the CLI — waits until no button is down and the mouse has been still for about 300 ms, and past about 3 s of waiting for buttons up alone. Motion a KiwiDesk control is making right now never waits: a hotkey, a menu row, a Settings action, a bar click, a drag or a scroll gesture moves at once, and takes anything held with it. A tail a command schedules (the switch’s settle, a follow’s reap, a launch follow) is the command’s own but lands later, when the next click may already be aimed, so it waits like ambient motion. Keyboard activity never counts: moving a window steals no keystroke.

Your own act in an app is not ambient either (#2187). Closing, hiding or minimizing the focused window, or a new window from the app in front, just after a key-down or click lays out at once — but never a window that left with its Desktop: ⌘W and ⌘N are as deliberate as a hotkey, and holding their reflow left a stack stale for seconds while the mouse moved. The press only admits such a pass; it never holds one. A window a background app opens, or a close nothing pressed for, still waits.

What waits is the layout pass, never its frames. A held pass is owed and run again once the hand rests, against the windows as they are by then, so nothing stale lands: a later pass, a closed window or a changed screen is simply part of what it lays out. Your own next pass carries the debt with it. A Space or Desktop switch is never held, wherever it comes from: holding it would show the new Space in the bar while the old windows stay up. The windows’ records, the bars and the rings move at once, and a held pass stamps nothing, no placement and no size ask, so a frame that was never written is never judged against an echo. Of two windows arriving during one hold, only the newer keeps its start-at-target entrance. The cost: after you stop the mouse, an ambient reflow lands up to ~300 ms later than it did, and while you hold a button it does not land at all.

A window a float rule or a detection just floated waits the same way (#2627): what is owed is the window, never its frame, and it is placed when the hand rests only if it is still a float. A window you drag or resize meanwhile stays where you put it. A frame that moves without a drag decides nothing: an app answering a size it refused and a hand move look alike from outside. A sticky window, or one floating because its Space floats, moved onto another screen after a display change or a move into a Space shown there waits the same way (#2628), its target worked out again from the Space it then belongs to; any other float’s move already rides the held pass. A window owed both is placed, or re-anchored where the placement is no longer due.

The frames a restore puts back, at launch or after a wake or an unlock, wait the same way (#2626): each is read again from the saved arrangement when the hand rests, and only while the same screens are connected and the window is still in the Space the restore filed it in. A newer restore, a sleep or lock, quitting, or discarding the saved arrangement drops what an older one was still waiting to put back. A floating window comes back with the held layout pass, as it did before. A window you drag meanwhile, or one placed or moved onto its Space’s screen, keeps that move: it reflects the desk as it is now, and the saved frame does not.

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, because every narrower discriminator failed on the device: 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. 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 are bounced inside the placement window if the report reaches 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 — but a global monitor never sees a press routed to our own windows, so a stamp written only from its fan-out misses exactly this window. The press fan-out 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.

A window returning from a close is a new window: it takes the focus at its arrival and lands where the user is (#1414, #1561). An NSWindow keeps its number across a close and a re-show, so a window the 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 set no intent, and the report reached the predicate clickless inside the placement window of the arrival’s own retile, which in the active scrolling Space is the whole verdict. The class is every app whose close hides the window (measured 2026-09-21 with Telegram’s main window: 3 of 3 bounced in scrolling, 0 in bsp), and a third-party window has no door to be told through — the per-window debt #1380 recorded for KiwiDesk’s own Settings window cannot reach it. So the provenance moves to where it is known: the gone handler already classifies a close apart from a Desktop departure and a hide, it marks the departure, and the fold reads the mark at the return and treats the window as NEW: placed by its app rule, else in the Space the user is on — never the Space it left, whose slot and break it gives up — with the focus a new window gets, the mark consumed on every arrival. The close outranks a session restore filed over it: a snapshot adopted between the close and the re-show names a Space and a frame the user has since closed, and the re-show discards both. That placement is the owner’s ruling (#1561): a window you bring back is wanted where you are, so you can move it somewhere else if you want, and a window manager opens windows where you are rather than sending them back to where they once were — on two screens, “where you are” is the active Space, so a window closed on the other screen and re-shown there lands as a new window would and the retile carries it across (#1010’s screen-home reads the memory the mark dropped); it also ends the asymmetry with a minimize, whose restore lands as new. The report then lands intended, which the predicate never reads: no exemption in it, the provenance the report was owed. #636’s rule stands for a Desktop return, whose windows come back as a burst in arbitrary order and where only the remembered focus is macOS’s (#1345), and #913’s for a hide, whose windows come back the same way — both to the Spaces they left, at their slots: the OS bringing a burst back, not the user bringing one window back. The #1380 debt is retired — the fold grants what it paid. Three trades, stated: an app that re-shows a closed window on its own, with no user act behind it, takes the focus once, where the user is; a closed window does not take back its slot and track break; and the two readings gonePresence calls a wrong closed it never corrects — a host without the compositor read past the switch settle, a fast app’s destroy landing before the topology flips — cost, on top of the close-return raise, a focus grant at the Desktop return and the window’s re-placement as a newcomer where the user is, its slot in the Space it left given up, priced at the same rarity. (ClosedReturnFocusTests, ClosedReturnPlacementTests, ClosedReturnSeamTests)

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 (an AX net, since retired) 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 with the gate in place): 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 2026-09-08 on the gated build: a gate reading the managed display’s “current Space” loses the race. While a swipe’s departures fold, the on-screen window list (the probe beside the log) has already dropped every Desktop 1 window and the current-Space reading still names Desktop 1 — so a Zen window on the Desktop being left reads as shown, the placement distrust re-asserts it, and the user bounces. 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 puts the gate at the verb rather than the AX call: focusWindow writes state focus, notes the displacement and warps the pointer before raiseWindow could refuse, 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 has to survive, both measured. 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 (2026-09-13, two displays, an event-stream trace of every removal); what remains open lives on #1364.

The gate asks two compositor reads, and either refuses (#1410). The flag alone has a blind spot (measured 2026-09-13, every gesture of the sitting): from a three-finger swipe’s first movement until the switch registers, 1.0–1.5 s later, kCGWindowIsOnscreen reads 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 reads false for the Desktop just left the moment the switch lands. 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 #1364 settle refocus (crosses=false at +1.2 s) is the measured instance, which is why that arm reads no flag. The current-Space reading fails the other way: 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. 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. Read as the latter, a non-activating panel shows 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). Honoring the flip moves the anchor onto Claude’s window, and the command preflight (#292) then refuses every focus chord as “frontmost pid is another app” until a click re-syncs the two — the preflight 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 app running as several processes qualifies “the app macOS activated last” per process — the #1785 entry below.

A shadow window is judged by its buttoned sibling; a pid LaunchServices cannot name is no identity (#1785)

Section titled “A shadow window is judged by its buttoned sibling; a pid LaunchServices cannot name is no identity (#1785)”

[Rationale]

A shadow is a window with no title-bar button and no accessibility child beside a buttoned window of its own process — Orion’s “Orion Preview”, which sits at 1×1 in a screen corner and comes forward, listed and focused, the moment its process steps back, hiding again as the process activates (device, 2026-09-30). Tiled, it took a slot and traded focus with its host on every click: KiwiDesk raised it, Orion hid it, the close-return raise fronted the host, Orion showed it again. What separates it from a frameless real window (a terminal, an Electron app) is the buttoned sibling: a real frameless window has none, and its content lists. A window’s own title element is no content: NoMachine’s twin, glued over its host’s content area, lists that and nothing else (#2346). The rule asks that of every window, whatever its subrole reads — the twin read AXUnknown as often as AXStandardWindow (device, 2026-09-30), and a subrole gate let it in as a float — and reads a window in one round trip, since Orion answered a read in 100–600 ms while busy. Only a process that shows no tracked window yet waits for a host before a lone shell tiles, because a fresh Orion listed the twin over a second before the real window (device, 2026-09-30); a shell in an app that already shows a tile is a window at once, so a decoration-less terminal’s next window pays nothing. That rests on the twin being one per PROCESS, which is what Orion showed: an app minting one per window would see each new twin tiled and handed back a reconcile later. A shell tracked while it still read as a window is handed back at the next reconcile as a HIDE, never a close, since nobody closed it; a read that fails takes no verdict back, so a known shadow stays one; and a record dies with its host, so a window judged a shadow beside a sibling that then closed is asked again. The trade: an app whose genuinely empty, frameless window sits beside a buttoned one loses that window’s tile while the sibling lives — no such app is known — and one whose only window ever is such a shell tiles up to hostWait late at launch (accepted-limitations.md).

A focus REPORT naming a shadow is dropped; a focus READ naming one is mapped. The twin takes its process’s focus as the process DEACTIVATES, so a report naming it says where the user left, never where they went — mapped onto its host, it moved focus onto the profile the user had just clicked away from, and back two seconds later when the clicked window’s own report landed (device, 2026-09-30). A polled read of an app’s focused window — at its activation, at the boot seed — is a question about the app in front, and there the twin names its process’s front-most tracked window: the two are a query and an event, and they carry opposite information.

A pid LaunchServices cannot name is not an identity, and among sibling processes the announcement names the app. A process an app starts as its own LaunchServices child (Orion’s second profile) is listed with pid -1 — even looked up by its real pid — while the WindowServer and Accessibility know the real one (device, 2026-09-29). Keyed on -1, every such process is one, so a pass adopts the WindowServer’s owner pid and an announcement without one is attributed to the unlisted process of that bundle. What an announcement cannot say is WHICH process: a click on the child’s window is announced under the parent’s pid, LaunchServices marks the parent active for 25–190 ms and only then the child, and the inactive profile keeps reporting focus for its real window meanwhile (device, 2026-09-30). So each process’s own report decides, and a report counts only while LaunchServices calls THAT process active, asked by its real pid — the one reading that answers what the announcement cannot; a record missing at that moment is no reading, and the report stands as it did before.

A record LaunchServices loses for a moment is not a process that exited. The record of a running process went missing for tens of milliseconds as its app activated — nine times in an hour’s probing, the listed process included (device, 2026-09-30) — and an ownership gate that read the absence as .prohibited detached the process with every window it held, to be re-adopted as new five seconds later. So a missing record detaches only a process the process table says is gone; until then the policy last read stands, for the float verdicts as much as for the gates.

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.

Disarming the distrust on the first focus report for any OTHER app — reading it as “the panel’s app resigned frontmost” — 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 hands 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. 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 a keyboard panel with Settings in front blip-keys Settings, whose clickless AX re-report trails the close (#952 — the panels’ activation 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 (#1130: the arrangement comes back, and the window the user went to rest in does 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.

Focus may run through KiwiDesk’s own raise; nothing else may

Section titled “Focus may run through KiwiDesk’s own raise; nothing else may”

[Rationale]

The #292 preflight refuses an implicit-focused shortcut while the macOS frontmost app is not the focused window’s. A focus command moves the anchor at once and raises the target, and the target’s app comes forward some time later: the target’s own focus report arrived 0.04–0.27 s after a refused press, and later while a slow Accessibility responder holds the main actor (#1812). A focus key pressed inside that gap is refused, and the keys seem to stop in between.

So focus alone passes the preflight while KiwiDesk’s own raise toward the anchor is in flight — pending a scrolling pan, or sent within the self-raise echo window — and the app in front is still the managed app that raise LEFT, recorded by the focus press itself — never by a re-assert of the same focus — with no app activation and no click since. That is not the case #292 guards: the window focus acts from is the one KiwiDesk just raised, not a hidden one, and focus changes no window’s content, size or membership — it moves state, pans to the target and raises.

It is an order question, never a presence one. A fresh raise says nothing about whether the user switched apps since, so any activation after the raise, or click after the press, ends the bypass — a switch away and a switch back to the app the raise left alike — and an ignored panel, an unobserved app and a stale raise keep the refusal too. The one-shot wake heal (#1130) is asked first, so a wake payment macOS declined reseeds rather than riding the bypass.

Every other focused verb still waits for the foreground. swap, resize, move_to_space and the rest act on the window itself — resize writes stored sizes and moves the neighbours — which is exactly what #292 protects, so a press inside the gap is refused as before and pressed again. A hold-and-retry queue could cover them, at the cost of ordering and key-repeat rules for a gap a tenth of a second long; none is built, and the issue records the shape one would take (owner, 2026-10-07). FocusRaiseFlightGuardTests holds the bypass, each refusal it keeps, and every other verb.

Hover focus fires on a pointer at REST, never a pointer that is merely there (#1869)

Section titled “Hover focus fires on a pointer at REST, never a pointer that is merely there (#1869)”

[Rationale]

mouse.hover_focus focuses and raises the window the pointer comes to rest on. Two choices carry it, and each closes a defect the obvious shape has.

The trigger is user motion that then rests: 200 ms with no move, jitter within a few points of the last rest not counting as one. A trigger read off “the pointer is over an unfocused window” undoes every focus change the pointer did not make: a shortcut, a scroll-gesture step, a close-return or a Space switch leaves the pointer where it was, and 200 ms later the old window takes the focus back. So only a global mouse-moved event arms the dwell, and a focus that moves while it runs stands. That closes the still pointer, and a small nudge needs the same answer: after the focus moves without the pointer — a shortcut, a new window, a Space switch — the windows the pointer is left over are HELD, and a rest on one stands down until a move leaves them all (owner ruling 2026-10-17). A 5 pt brush on a trackpad is not a choice of the window under it; entering another window is, which is sway’s focus_follows_mouse yes and Hyprland’s mouse_refocus = false. Coupling hover focus to mouse.follows_focus would hide the same gap by moving the pointer for people who chose not to have it moved, so the two stay independent. KiwiDesk’s own warp posts no mouse event (input-and-animation.md), so mouse.follows_focus lands the pointer on the window that already has the focus and the two never loop. A dwell timed from ENTERING a window would steal focus whenever the hand crossed a wide window slowly; a rest is blind to window size. It is longer than the bar peek’s dwell because a peek is a label and a focus redirects typing. Settings offers the delay beside the switch and Lua (mouse.set_hover_focus_delay) may go past its 2000 ms edge: a user coming from an immediate trigger, or crossing windows on a trackpad, has a real reason to move it, and nothing about a delay is risky enough to keep Lua-only (owner, 2026-10-17, #2379). It is per-profile because its switch is: a switch and its timing kept in different stores would disagree, and moving the mouse group app-wide is its own crossing.

The target is whatever the WindowServer composites in front at the pointer, read through the one front-to-back seam, and only another app’s tracked window counts. A menu, the Dock, a bar, a popover, Spotlight or another app’s panel in front stands hover focus down over its area; a state-only hit test would focus the window under an open menu. KiwiDesk’s own click-through overlays (the ring, the marks, the cues, the KiwiVision plate) are passed through. Its marked window, Settings (OwnWindowTiling), is focused by the same command as any window and then fronted by the GUI, since only AppKit can make KiwiDesk key while another app is in front (#1281); every other own window, a dialog or the tour, stands hover down.

It always RAISES. Focus without a raise needs a private event path no public API offers, and a hover focus goes through the focus command like any other, so its report is our intent rather than a clickless focus the #1161 distrust bounces, and it records the raise flight the focus verb does (#1812). A focus that would move any window of the Space — a scrolling pan under a re-seating anchor or towards a peeking slot, a monocle flip onto a parked window — is not taken: it would move the row under a resting hand, and the next rest would land on whatever slid under the pointer. A window on another screen switches the focused screen first, as a click there does (#446). A rest within the settle after a Desktop switch is dropped, as a click’s display follow is. The setting lives beside mouse.follows_focus and shares its per-profile scope (accepted limitations).

[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.

A lone window fills the screen, or keeps the room a neighbour would leave it — never a third size (#1389). On a wide screen a scrolling slot of 95% or a 60% master ratio leaves one window narrow with the rest empty, so the default is that one window takes everything and sharing starts with the second. The toggle’s OFF arm had two candidates: a lone window at its slot or master share, or a centred one at some third size. The ruling is the first, because the setting is about SIZE: OFF draws the exact region the two-window layout gives its master or its slot (the same clamped ratio, the same side of the split), so the one window is already the size a neighbour would leave it. Which window keeps that region when the second opens is new_window_placement’s question — Stack’s default first makes the newcomer the master — and where a slot sits along the row is the anchor’s (#1388); this setting answers neither. The toggle is about ONE window: two or more windows with no stack zone — every one a master — still fill, since no second-window region exists for them to keep; so under master_count 2 or more the lone window’s kept zone widens to the shared full area when the second arrives, a consequence stated rather than a case the setting carves out. It is per LAYOUT and not per Space, a layout-wide shape rather than slot_size’s: what a lone window does is a taste about the layout, the same on every screen, and an override map is additive if a per-Space wish ever arrives. Default ON is a behaviour change to nothing — every profile drew that way before the key existed — so the flag is sparse-decoded with no migration, and the release notes name the switch so the user who wants one window kept at its slot knows there is one.

The fixed anchors are absolute; follow alone keeps the screen filled (#1388). One clamp shared by every anchor — the row’s extent stays on screen — pulls a short row flush to the leading edge whatever the anchor says, and on an ultrawide a lone or end-of-row window sits wherever the row ends rather than where the user pointed — a long way from “in front of me”. The clamp is follow’s promise: that anchor exists to keep the screen filled and the side you came from in view, and without it a scrolled row that shrinks — a window closing, a resize — would leave empty margin past its trailing end where nothing asked for one. center, start and end promise a position instead, and a position honoured only when the row happens to be long enough is not the setting’s name. So they compute their rest and use it — a lone window under start sits at the edge with the rest of the screen empty, by design. A “don’t clamp” toggle beside the picker was rejected: the anchor’s name already says what the user wants, and a second switch to make it true is one control too many. With “If one window, fill the screen” on (#1389) the lone case never arises; the anchors matter for the rows fill leaves alone.

A scrolling share is a share of the pitch (#1382). A slot of “50%” was half the bare axis, so two of them plus the gap between never fitted the screen — the second column always hung one gap past the edge, and “33%” three columns was a puzzle nobody could type. On a wide screen the value a user holds is the effect, not the share: three columns, not 33.3%. So a fraction resolves as f·(along + gap) − gap — the share of one window plus one inner gap, less that gap — and n slots of 1/n tile the axis exactly at any gap on any screen. The focus ring paints inside the gap and takes no layout width, and after Fit layout gaps the gap is at least the ring’s reach, so the count holds with rings clear. Points stay the absolute channel. No migration is owed, and not only because the shift is small: no file-local crossing EXISTS — the fraction that would redraw the old picture is (f·along + gap) / (along + gap), a function of the screen’s length a profile does not know — so the trade is a shift of at most (1 − f)·gap on every screen, stated here so profiles.md’s “meaning changes under the same key” clause (#1354) is answered on both of its bounds: every stored value moves by under one inner gap, and the old reading was the defect. One representation stays: a count the Settings window offers writes 1/n into this same stored fraction, never a second number.

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. 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, as the #966 paragraph above says: “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 is reachable — the layout draws min(along, …) — so the both-borders arm is a case this rule has to answer regardless of the ceiling below.)

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.

On KiwiShelf the bar strip is reserved in layoutBounds before any layout runs, so no layout carves a bar of its own and the difference between the drawn area and the region is the outer gaps alone; the argument is unchanged, only the bankable width shrinks.

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 a width-bound window centers with symmetric gaps the stack shows through with no transparency involved (#880). 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 a Monocle focus change is a raise and nothing else. 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 Monocle focus change is a raise and nothing else, with no cue (#2099). A focus step, an App Bar click or focus_or_spawn in a Monocle Space lands the ordinary focusWindow: the window comes to the front, and nothing is drawn over it, whether the App Bar is on or off. No setting and no Lua verb govern it, since there is nothing to switch.

The landed window identifies itself. It fills the screen with its own title bar and content, so a plate naming it says less than the window already does. The one case a plate seemed to help with is three windows of one app: they share an icon, only their titles tell them apart, and the title is already on screen. An app that hides its own title still shows its content.

A plate belongs to a change of arrangement, not to a step. #2142’s plate, which #2228 shows for a Space switch too, answers a change where screens can look alike and a name is the only cue. A Monocle step always changes what is on screen. macOS’s own analogues — ⌘` cycling an app’s windows, a click on a window — raise with no overlay, and a plate on every press of a burst would cover the window the user is trying to read. Monocle with its App Bar turned off is a choice for bare windows, so the bar’s absence opens no gap a cue would have to fill.

The accepted trade: no direction. In Monocle, direction is array order, and the key the user pressed already states it.

Removed, not defaulted off. animations is written whole, so every existing profile stores the flip’s old default, and turning the default off would leave it on for all of them; an opt-in would keep the owed-focus ledger and its hooks in every focus path for the few who chose it. The stored keys need no crossing: the animation settings decoder and a look’s admission both skip keys they do not know, and a profile drops them on its next write.

Revisit this only on device evidence that a wordless step leaves users unsure which window came in — and then try a title-only plate behind the App-Bar-off gate first, never an always-on cue.

Monocle is not Scrolling at 100 %, because it moves differently (#2099). At rest the two look alike: Scrolling’s edge slivers (ScrollingLayout.edgePeek) sit under a full-width focused window, so neither shows more than one window. On a focus step they part. Scrolling pans the row, so every window moves; Monocle with stack raises one window and writes to no app, so a slow app costs it nothing. A strip you pan along and a deck you lift cards from are two ideas, and one name for both costs simplicity. Folding Monocle into Scrolling would also cost a stored-mode crossing and retired verbs, grow Scrolling a “stack the scrolled-out windows” option that is Monocle under another name, and open the smallest screen (#1662) in “Scrolling, special setting”. A Monocle with no chrome needs no layout of its own either: its per-layout App Bar row turned off is that already.

A Space switch slides, shows the Space, or neither — one app-wide choice, the slide by default (#2524). An explicit switch — focus_space and the Space history and order steps, the scroll Space step, a move-and-follow, Open or Focus onto another Space or a launch follow — shows itself as space.transition says: Slide, the #1956 plate slide; Show the Space, the instant switch with #2142’s plate naming the Space and its layout on the screen that switched; or None, the instant switch alone. Every switch is one instant pass underneath, and the choice changes only what is drawn over it.

Why three, and the slide first. The slide and the plate answer different questions. The plate says where you are now, at the instant switch’s cost; the slide also says which way the switch went, and shows a follow’s window travelling with you — the two things the plate cannot, and the direction cue is what users asked back for once the slide was gone. Slide is the default because it alone answers both questions — where you are and which way you went. Its cost comes with it: it holds the GPU at 55–65 % switching back-to-back where the instant switch holds 33 % (measured with glass plates when #1931 made the slide the default), and it carries the most fragile machinery in the switch path, the write hold and the plate builder. Show the Space is one pick away for a user who wants the plate’s answer at the instant switch’s cost, and None for one who wants no cue at all.

Reduce Motion shows the Space. Under Reduce Motion a Slide draws Show the Space, the stored pick untouched, and the plate drops only its fade: the plate is how a switch reads with less motion, so the fallback keeps the answer and drops the motion. SpaceTransition.drawn(reduceMotion:) is the one copy of that fallback: Core’s KiwiCore.drawnSpaceTransition, which the slide and the plate both ask, reads through it, and so do the Settings rows, so neither the two cues nor the card can disagree about which one a switch draws.

Show the Space shows everywhere. A user who picks the plate has asked for it, so it shows on a screen that draws a Space Bar too. Standing it down there made the setting look dead to anyone whose Space Bar is on every screen, and the plate says the Space once, for a moment, where the bar says it all the time.

Except where the Space Bar picked the switch (#2591). A chip, a glyph or a list row clicked on the Space Bar names the Space the user chose, so a plate naming it again is noise. paySpaceCue stands down inside the one fromSpaceBar scope, which the bar’s own pick doors open. The slide still plays there: it answers which way the switch went, and the click does not answer that. Under Reduce Motion, where Slide draws Show the Space, such a pick draws nothing. A Space reached by any other door keeps the plate, the window switcher’s included: its pick names a window, not a Space. Accepted: a Lua callback the pick fires inline, such as a space_change handler that switches again, runs inside the scope and shows no plate either.

Neither cue plays over a full-screen app or a presentation. The plate and the slide both stand down where the shelf does (shelfStandsDown), and the switch there is instant with no plate of either kind: a plate over a slide show is the intrusion #1787 keeps the bars from, and a strip of plates sweeping across it is the same intrusion, larger. The slide does not fall back to the Show plate there, since that plate stands down for the same reason (FullscreenStandDownTests ▸ fullScreenKeepsTheSlideAway).

On the Animations card, apart from the window rows. The picker and Slide duration sit at the top of Looks & Animations ▸ Animations, above Animate windows, which does not reach them: that master paces windows, and the slide moves none. Reduce Motion does not grey the picker, since the fallback above is the answer that setting asks for. The duration greys whenever no slide would play — the pick is not Slide, or Reduce Motion is on — read through the same drawn(reduceMotion:), and both grey where init.lua owns the config, as the Desktop cue’s row does (#1741, #2142). The group ends with the note that it applies at once in every profile and a divider, so the window rows below it read as a second subject.

App-wide, and crossed from the toggle. It is stored in gui.json as space.transition and space.slide_duration, beside desktop.cue and mode.cue; nobody varies it per profile (#1741). The toggle before it, space.cue, is written whole, so a stored true cannot be told from the default: a one-shot ConfigMigration step rewrites true to Slide, the new default, and false to None, which keeps the user’s off, in gui.json and in a setup bundle’s inline config (SpaceTransitionMigrationTests). The cost is a user who turned the plate on deliberately meeting the slide once, one picker step from Show the Space. The retired verbs are refused, naming the new ones, rather than aliased.

No crossing from 2.x’s per-profile slide. 2.x stored the slide per profile as animations.on_space_change and animations.space_change_duration. 3.0–3.2 already dropped both on load and wrote neither back, so a crossing could reach only a profile no 3.x build has saved, and would revive a per-profile choice into an app-wide one three releases after it stopped meaning anything. The cost is a 2.x user who had the slide off meeting Slide, one pick from None.

The plates wear KiwiHub’s glass. Each plate is Liquid Glass, .clear with KiwiShelf’s Fill and the key-window rim under Tint KiwiHub and plates and .regular and untinted with it off (#2569, ▸ the shortcuts panel entry). Each pile is one container so overlapping plates read as glass on glass, read from KiwiHub’s leaf of the Liquid Glass switch rather than a leaf of their own: a surface drawn for half a second needs no switch of its own, and one leaf fewer is one fewer the Liquid Glass switch has to keep in agreement. With glass off, under Reduce transparency or before macOS 26 they take the blur nearest KiwiHub’s own fallback, so the two keep one look. A tinted plate’s variant is pinned dark by a dark Fill, as a bar’s is (▸ Liquid Glass). Under a light Fill, and untinted, a plate follows the Appearance pick as KiwiHub does, since AppKit glass pins only dark: each plate’s glass and pile container is pinned dark where the panel’s effective appearance resolves dark, through GlassTint.followPick (#2539), rather than left to inherit it. An unpinned glass view carries the pick (#2543) but adapts to the backdrop it samples and holds that verdict (#1308), and a plate’s backdrop is the windows being switched; the pin keeps the variant the pick’s whatever the plates cover. No switch of their own: only dark can be pinned on AppKit glass (#1308, Liquid Glass above), so a light choice could not hold over dark windows, and plates unlike the rest of the app are not worth a setting.

Why plates, and not a blur or a picture of the windows. A blur dissolve was built on the device and dropped: a blur reads as something in front of your work, and a jump still showed as blurred shapes. A picture of the real windows needs Screen Recording, which the owner ruled out for an animation (#1391’s measurement). A plate per window, carrying its app’s icon on the part the screen shows, says what moved and where without a pixel of the window. The desktop picture was tried as a ground and dropped: reading the file triggers a folder-access prompt.

The windows move once, underneath. The corner slide (#207) animated the windows themselves: every frame an AX write to every window of two Spaces, and a slow app’s write sticks for half a second inside its own commit, so it stalled exactly where it was meant to feel smooth. The plate slide parks the shown windows AT the press, moves the plates as one rigid strip one screen page in the Space Bar’s reading direction on a critically damped spring — no overshoot, so the strip never swings past the page it lands on — and lands the incoming windows under their plates, one write each, as the instant switch sends. SpaceSlidePlan holds the times.

The timing follows the apps, not the clock. An app performs a park 60–100 ms after the write leaves, so the strip waits 120 ms after the press; the incoming writes are HELD per window until the strip has settled and leave from each app’s own queue at that deadline, so a stalled main turn delays no landing; the plates lift once the landed windows have had their own time to show. A press while the strip still moves carries its position and speed into the next page rather than restarting, and a window whose write was still held never showed, so the strip need not wait for it to park.

Piles show one face. Every member of a pile keeps a plate at its frame; only the front face carries an icon — the focus if it is in the pile, else the WindowServer’s front-most member, read once at the press, since parking moves frames and never restacks. A plate wholly behind another is dropped, so a Monocle Space shows one plate, and a scrolling row only its on-screen columns. A sticky window stays on screen through the switch, so it draws no plate and the strip is cut around it, and a follow’s moved window, already on screen in the Space it goes to, travels with the user uncovered.

Its own pace (#1931). space.slide_duration is the strip’s spring response, and only the strip’s travel follows it; its default is 300 ms (SpaceSlideDurationTests ▸ paceScalesTheSlide). A slower pace does not buy readable icons: the strip crosses the screen too fast to read at any pace in the band, and what reads is the arriving plates at rest, which 550 ms lengthens by about 45 ms while lengthening the whole slide by about 380 ms. Under a saturated GPU the switch work that overlaps the plates grows with the pace too, and 300 ms is the one pace measured steady there. The two waits on apps — the strip’s delay for the parks and the landed windows’ margin — do not scale, since no pace changes how long an app takes. Nor do the fades, and neither has a setting: the fade-out, 300 ms, is how softly the windows appear rather than how fast the slide goes, and the fade-in, 80 ms, must leave the plates opaque before the outgoing windows park. animations.duration keeps pacing windows only.

One panel per screen, ordered in once. Moving or re-ordering a panel per switch queues behind the compositor exactly when the switch is busiest (#1960), so each screen’s panel stays ordered in, dormant and empty between plays; everything that moves is a layer. KiwiDesk’s own windows move through AppKit inside the switch’s own turn, since the main queue would run their park only after it.

The focused ring waits for its window. A slow app’s window can land after the switch, so the ring appears on the window’s own report near the frame it was sent, or at a cap, never ahead of it (#1959). Inside a Space the ring still leads a focus move, since there the moving ring is the cue; after a switch a leading ring would sit on an empty spot.

Nor does it appear before the slide’s plates lift.

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:for:): the visible frame with the Space Bar’s strip already reserved (#293), which is the region the layout actually filled. A resize path reading the raw display instead (the keyboard span, the BSP focus sign, the finished mouse resize and the scrolling slot’s seed all did) understates every ratio nudge by the strip with the bar on — the default — and the scrolling slot stores 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.

The reserved strip is KiwiShelf’s, taken in a layout exactly where a bar draws there — every layout while the Space Bar is on, else only the layouts whose App Bar is on — so the region is the one the Space’s own layout filled, and a span is measured against it whatever the other layouts reserve (#1517).

Interactive resizes are session-scoped per space; the config layers never move underneath them (#458). A resize on a space with no authored override writing the global ratio is coherent under the #17 layered model (“you resized the default”) but visibly wrong the moment two monitors show two no-override spaces: resizing one resizes both. The two rejected alternatives: keeping that (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 — config stays untouched, and the layer reseeds on a real mode change or reload_config. 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.

The session layer outranks the authored override, and a resize never writes an override (#458 as amended by #764). Read precedence is session > authored override > global, and every interactive write lands in the layer — on a space with an authored _override too, where it used to edit the override in place and so destroy the number the profile wrote. The override is then the authored value by construction, which is what lets reset_layout_sizing return to it. Two costs follow and are accepted: a per-space _override setter must clear its field on that space, or the explicit write is shadowed (#383’s trap one layer down); and an event-driven apply that CHANGES the profile — a bound-Desktop switch — reseeds the layer after all, since the values in it are the outgoing arrangement’s and would now outrank the incoming profile’s authored ratios; the same-profile monitor-change apply keeps it as before, and so does the monitor-change apply that hands the slot to a built-in Standard — a Standard authors globals only, which the layer has outranked since #458, so nothing of its own is shadowed. What the Settings override editor and the quick menu’s Keep show and write is the authored number, not the resized one — a resize is session-only on every space alike, which #1179’s “Settings narrates the profile” ruling already holds for the no-override case.

A reset of layout sizing returns to what the profile authored and touches size alone, never structure (#764). Interactive resizes accumulate per space and across weeks, and the two routes back before reset_layout_sizing — nudging each ratio in the opposite direction, or load_profile, which also discards every non-sizing change since — were not answers. The verb clears the two stores a resize writes (the #458 session layer and the stack and track weights) on the active space by default — a reset is about the manual resizing in front of the user, and a bound key should undo what they just did, not what they did elsewhere — on one space by id, or on every space for all, which is the week-later case where the problem is precisely not knowing which spaces drifted (owner ruling 2026-09-21; the issue asked for the universal one first). Two rulings hold the shape. It resets a space to what its profile or init.lua authored — a per-space _override size field survives, and only where nothing was authored does the space land on the global — never past either to the shipped default, which would destroy configuration the user never touched in the session; none of that is what “reset my adjustments” means to anybody (owner ruling 2026-09-21). Restoring the authored value by remembering it — a snapshot of the four size fields at every profile, config and Standard apply, refreshed by every _override verb — is rejected: it is a register of writers, and the one that forgets makes the reset restore a stale number with nothing going red. Instead a resize never writes the override at all (the #458 amendment above), so the reset restores the authored value by dropping the layer in front of it. And it stops at size: masterCount looks like a size and is a count of which windows are masters; the strategy, orientations, positions, placement, anchor, axis, limit, grid dimensions and the track breaks are choices the user made, not drift, and a verb that cleared them would be a second load_profile. What the verb lands on is the authored value where the members’ learned floors allow it: the retile it triggers runs the split-floor heal, which is the standing exception. The noun is sizing — the collective of a layout’s size adjustments (.claude/rules/config-vocabulary.md ▸ noun glossary).

Resize is truly 2-axis via two per-space BSP ratios; per-node ratios are rejected. With resize("x") and resize("y") writing the same scalar (one splitRatio for every BSP split, one masterRatio for stack) the axis only scales the step, so a “resize vertically” key visibly changes column widths. BSP has 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 are 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 has 5 rows (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. 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 (so a focused stack window grows its zone rather than the master — 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 no longer draws the weighted shares (When windows no longer fit), 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). (#67)

A mouse drag along the zone’s own axis moves the weights as the key does (#941). #67 kept that drag snapping back on one premise — the mouse-drag seam carried no window identity to key a weight against — and #925 ended it: the drop hands the dragged window to applyResizeAdjustment(for:), which is what let the track layout’s in-track share take the same drag, and the interaction users learn in Track is the one they try in Stack. The drop takes the one resizeStackMember the keyboard verb takes — the same #67 step, the same #933 clamps and refusal pills, the same #308 refusal of a window that is no member of the Space — so the two paths cannot drift, and the dominant delta of a corner drag decides between the split and the share, as bsp and track already decide. What stays: a master zone lined up along the split has no cross-axis share, and the writer refuses that drag exactly as it refuses the key.

The stack zone’s lineup derives from its position — no stack_orientation knob; piles always cascade downward. The stack arrangement is configurable (#222): 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): 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. If pile depth ever hurts, the lever is 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) A 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 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 overflows, the windows it sets aside are the array-earliest masters instead of the latest, at the slot farthest from the seam.

Stack and Track scroll their overflow (#2235). A Stack zone or a Track track that cannot fit its windows at their floors shows as many as fit from where it rests, each at its floor plus a weighted share of what is left, and the rest wait off screen, parked at the hide corner. Focus drives the run by the smallest step that shows the focused window: the focused window joins as the last in line (the first, going back), the run gives up windows at its other end only until it fits, and nothing past the focused window is revealed. A run at rest keeps both its ends (Space.runHeads, runTails), so no pass adds a window a step did not ask for, and a close leaves the shown windows to grow into the room rather than revealing more. A run whose windows’ floors differ would otherwise reveal two or three at once when a step dropped a large one, which reads as a jump (owner, device, 2026-10-22). Every zone that overflows scrolls on its own, as each Track track does. The master zone still holds master_count windows and sends every further one to the stack zone; it overflows only when master_count itself is more than fits. The master zone is no exception: more masters than fit is the same overflow, and one behaviour for both zones keeps a window’s treatment from changing when promote or demote carries it across the boundary.

Why a scroll, not a cascade. A cascade spends the zone’s length on title-bar slivers: past a few windows every member is a strip too short to read, and the way to the one you want is a click on a sliver. A run keeps every shown window usable and puts each hidden one a focus step away, the answer Scrolling already gives. With that, the overflow style that chose between two cascades has nothing left to choose, so it is retired rather than given a third value.

Rejected. A fixed visible count or share: it is wrong for some screen or some app’s minimum, where the floors already say how many fit. A zone-only scroll gesture: the ⌃⌥ scroll steps focus in every layout, and focus already drives the run. Overhang past the zone’s edge and parking behind the master: each puts a hidden window where it is drawn over something else. An App Bar that appears only on overflow: its reserve would resize every window the moment the count crosses the edge, against #761’s rule that only a setting moves the windows.

Parked at the hide corner, and a park snaps. A hidden window rests at the hide corner, at its own learned size, as monocle’s park does (#881): the corner and its sliver are #410’s, chosen over screenNeighbors (#878) so the body falls into dead space. Waiting behind the edge slot was the first ruling and failed on the device: macOS cannot clip another app’s window, and one whose minimum exceeds the slot keeps its origin and grows down into the shown run (Zen, 495 pt in a 432 pt slot, peeking through the gap below it). Only the corner puts that overhang off screen for any app. A park or an unpark snaps on every pass, the animated ones included (TilingEngine.parkSnaps), while the shown windows glide one slot: the step still reads as a scroll. The window entering is always the focused one, and focusing raises it, so it can never slide out from under its neighbour — it appears in its slot on top. The window leaving drops to the corner. With nothing stacked under the run, a scroll owes no z-order restore, and the zone restores leave the parked windows out, which would otherwise read as a pile at the corner. Flying from the corner was declined for the default: monocle’s park already rules that a focus change is a raise and never a corner flight (a separate setting may offer it later).

One record of what a run shows. The engine records each shown Space’s runs inside the retile pass that draws them (TilingEngine.runCovers), and the z-order restores, navigation, the weight resize, list_windows and the App Bar’s visibility all read that record. A reader judging a run from the frames alone sees the parked windows piled at the corner, or, as it was under the first ruling, a hidden window at its shown neighbour’s frame and one of the two covered by the other (#2547); the record says which wait. The weight resize is the same rule applied: inside a run it divides only the span left after the shown windows’ floors, which is the span the layout divides (#933), so a press never writes a share the run does not draw.

Learned floors, judged once per pass. A floor is the configured minimum, or an app’s corroborated learned minimum where larger (#677/#1055) — the bound Track’s automatic count already reads (#1355). Reading it keeps a shown window from being asked for a size it will refuse. Judging it from the bounds the pass starts with, never by comparing two passes, means a floor learned during a pass moves the next pass’s run and cannot make the run oscillate. A forced pass reads the configured minimum alone, as TrackLayout.geometricCap does, so it still probes past a learned bound. The cost is the first placement: a floor is learned only after an app refuses an ask, so a window may overhang for one pass (Accepted limitations).

The retired setting crosses by migration. overflow_style is stored in profiles, gui.json and backups. A decoder that skipped the key would keep the dead value in every file with nothing ever signalling that the last one is gone; the one-shot migratingRetiredOverflowStyle step removes it everywhere, global and per Space, and the format bump makes an older build read the rewritten file as newer than itself rather than write it back. The four verbs join APIReference.retired, refused with their reason, never aliased (AGENTS.md §5).

The overflow mark: a “+n” on each hiding end. Each end of a run that hides windows carries a mark on its edge window, counting that end alone (OverflowMark, derived from runCovers and never stored, so any layout that records a cover gets its marks). It shows whenever the end hides windows and has no setting: a mark the user could turn off would leave windows reachable only by stepping blind into them. It is a mark, not a bar, so it reserves nothing and no window moves when it appears — the #761 rule the App Bar alternative broke.

It wears KiwiShelf’s look — the Fill, glass where the shelf’s glass is on, the item ink and the rim — because clicking it opens the hover peek, which wears that surface: the mark and the list it unfolds read as one object, and the ink-on-Fill pairing is the peek’s, already measured on every palette. The Space Bar’s badge colours were the alternative; that badge marks a bar item, and the peek is the closer relative. An accent fill was refused outright: the edge window is often the focused one, and an accent on its ring reads as part of the focus cue. The sticky mark’s plate was too small to read a count on.

As shallow across the edge as the text allows. The mark is centred on its edge window, its outer edge flush with the layout bounds, so it fills the outer gap and reaches onto the window by its depth less that gap — 8 pt at the default gap, clear of a centred title and never onto the shelf, the menu bar or the notch. Off centre was refused because the trailing corner holds toolbar buttons and the sticky mark, and the leading one the traffic lights; a mark takes the clicks of what it covers, and the centred title is the least interactive thing a title bar has. On a top or bottom end the count runs along the edge, so the mark is an 18 pt pill whose width grows with the count; on a left or right end it runs across, so the mark is a circle sized to the text. Those are one rule, not two shapes to merge: a circle on a top end grows taller with a two-digit count and covers the title again, and a pill on a side reaches twice as far onto the window, over a scroll bar or a sidebar.

It lists that end, and only a click opens it (#2563). The peek lists the end’s windows nearest the edge first — the order one focus step at a time brings them in — and opens into the run. Hovering the mark lights it and opens nothing; a click opens the list and holds it, a click on its header closes it. Every bar list opens on its click the same way (#2571, below); the mark has a reason of its own besides: it sits on the title bar of the window the pointer is on its way to drag or click, so a hover peek covered the very window the user was reaching for. A click opens rather than steps, as a click on the Space Bar’s +n does: one meaning for +n in both places beats a cleverer “step to the next” here. The second click of a double-click is not a close, so a double-click leaves the list open (#2063: a double-click stays harmless). The list stays open when the pointer leaves it, because a held list that closes on leave is hover again; it closes on a click outside it, a pick, Escape, and anything that makes it stale. It takes the keyboard while open, for Escape and the arrow keys over its rows, without activating KiwiDesk. Every group wears its app’s icon, even when every row is one app: the mark shows nothing but the count (owner, device). VoiceOver reads it as a button naming its direction and count, and its press opens the window menu.

The pill becomes the list. On a click the mark’s own plate grows by its frame into the list, the “+n” staying where the pill sat as the list’s header and taking the list’s ink, the rows fading in over the second half; closing shrinks the list back into the current mark. The mark and the list it opens are one object — that is why the mark wears the peek’s surface — and the motion says so where a fade could not. It is the peek’s opening motion for this one source, never a second peek, so the bars keep one peek. No scale: an 18 pt pill scaled up squashes its count and rim, and a list scaled from it warps rows until they cannot be read, which is also why a genie was refused, besides meaning “minimized to the Dock” and needing a private API. One critically damped spring with no overshoot, about 200 ms open and 160 ms closed. Under Reduce Motion nothing grows — the pill fades out and the list fades in where it rests, the “+n” holding its place — so the mark’s entry and its list are the only motion it has. Reduce Motion is a cross-fade, not an instant open, so the peeks have one off switch of their own, animations.on_peek: off, the list and the hover peek’s fade show and hide at once. It covers both because the bars keep one peek, and one rule for its motion beats two. Borrowing reveal_duration was refused: that is a hidden bar’s pace, and a “Reveal duration” row quietly governing a list is a link nobody would find. Its appearing and going still ride the park snap and its window’s glide, with no motion of their own.

A full Grid scrolls its overflow through its last track rather than piling it (#2541). The last track along the fill order — the last row when columns fill first, the last column when rows fill first — becomes one run (RunZone), so Grid adds a zone rather than a second scrolling mechanism: focus drives it, the hidden windows park at the hide corner, the overflow marks count each end, and every run reader takes it from RunCover unchanged.

Fixed cells or floors. A grid with fixed columns and rows shows exactly its last track’s cells, lined up with the tracks above, so the run never reflows the grid’s columns (RunZone.slots); an auto-sized grid’s cells already are the minimum size, so its track shows as many windows as fit at their floors (owner, 2026-10-25). An app’s own minimum still wins inside the track: a window too wide for its cell shows with fewer beside it.

The typed columns and rows are a limit. Where a cell would fall under min_window_size, the grid lays only as many columns and rows as fit, the way auto-size already caps, and the rest scrolls (owner, 2026-10-25). The cascade is left for a screen too small for even one cell. The names stay: set_dimensions already meant “up to” for a dynamic grid, nothing stored changes value, and the Settings rows read “Column limit” and “Row limit”.

The cap is the largest floor among the windows (#2687). An app that will not shrink below its own minimum — learned from its refusals, as every layout learns it — lowers the grid’s columns or rows, fixed or auto-sized, until it fits a cell; the grid grows back when that window leaves. A cap read from min_window_size alone laid cells no such window could take, so it overflowed its cell and covered its neighbours (owner, 2026-10-27). The floor is learned from refused asks, with the latency the split-layout row of accepted limitations states. Track differs on purpose: its fixed limit stays the user’s number (#1355). The ruling covers Grid alone, whose columns and rows were already limits (#2541), and Grid takes one Space-wide floor per axis where Track’s automatic count fits each track to its own members.

The scrolling track is as deep as its widest member needs. Across the scroll axis the last track takes the largest learned minimum of all its members, hidden ones included, as a Track track does (#1355), so it keeps its size as it scrolls; along the axis no member lifts a column or row, since the members move.

Rejected. Scrolling the whole grid, or any track but the last: every other cell would move on a focus step, where the last track alone keeps the rest of the grid still.

A full BSP Space scrolls its last line rather than piling it (#2542). Where a split cannot hold two windows at min_window_size, the trailing chain of same-direction splits becomes one run (RunZone), as Grid’s last track does, and every run reader takes it from RunCover unchanged.

Along the last split, never across it. The last split made is known to hold two windows at the floor, so along it a window whose learned minimum is larger than the floor still fits; across it the region holds one floor, and an app whose minimum exceeds the floor — common — could not fit there at all (owner, 2026-10-25). The whole chain scrolls, not the last slot alone, so every window the last same-direction splits placed moves as one line, the way a Grid’s last track does. Windows split off in the other direction stay put, and before anything overflows the dwindle is unchanged.

The line is as thick as its widest member needs. Across the scroll axis the split before the chain gives the line the largest learned minimum of all its members, hidden ones included, so it keeps its size as it scrolls; the window split off there keeps min_window_size, which caps the line where the two cannot both be met.

A mark facing a fixed window straddles the gap. The line can start beside a fixed window — under alternating once two splits precede it, and under longest_side too wherever the splits turned and turned back — so that end’s mark sits across the inner gap and covers a few points of the neighbour, rather than inside the line, where it would cover more of the edge window (owner, 2026-10-25).

The pile stays the last resort. Only a region that cannot hold one window at min_window_size still cascades, matching the whole-zone pile of the other runs. With no layout piling part of a Space, the “Sticky windows can’t be moved to the pile” refusal (#438) has nothing left to refuse and is retired.

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 would collapse the whole space into the OverlapStack cascade the moment a second window opened (#44), so 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. The same principle holds for BSP (#383). An extreme BSP split ratio does not collapse 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 a region genuinely too narrow for two min-size windows at any ratio is the overflow’s to answer, never the ratio’s (#2542, above). (#44, #383)

BSP keyboard resize is focus-aware in direction only — and some nested windows cannot grow. Accepted, by architecture. resize infers its sign from the focused window’s slot (#122; 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) is rejected — it cannot help the pinned case and would split the unified mouse/keyboard rule. The real answer is the track layout (#128), 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 Floating’s 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 min_window_size of the layout that places it (Floating’s for a float), 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 reads as “nothing happened” the first time (#933).
  • 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 changes what the layout draws, 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 anchors on the frame state held before the gesture’s first event — measuring first-event → last-event resizes 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. A learner confirming bounds from redraw latency makes the pill assert limits that do 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.

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 seed, refresh and clear; they do not 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 is 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. Implemented and measured, the owner ruled the other way: with the learner fixed, the bounds it 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 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 that defect. 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.

One family draws without the sound by construction: a refusal only an AX walk of another app can give (#1518’s New Window and Close Window). The walk runs off the main actor and answers after the hotkey fire that asked has ended, and the sound is a fire’s, so the pill is the whole cue. RefusalCueSeamTests names the unsounded pill door’s callers, so a third is a decision rather than a slip.

The setting is OFF by default, and the DECODER is what delivers that — the retired resize.feedback is not 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.

Where the switch sits is a STORAGE decision wearing a placement question: it is on General because the value is app-wide, in gui.json, and written the moment it changes. The storage, the crossing out of the profiles, and why General’s card needs the immediate write are argued once, in Spaces, profiles & config ownership (#1741).

General is the home of an app-wide value with no feature of its own — this sound, the quit layout — and not of every app-wide value (owner ruling 2026-10-11). A switch that belongs to a feature sits beside that feature, where someone setting the feature up looks for it: the Desktop switch cue first in Shortcuts & Gestures ▸ macOS Desktops, above the shortcuts whose switches it marks (#2142, #2655), the layout plate’s in Shortcuts & Gestures ▸ Layout under the Layouts to cycle row (#2144). Such a switch is still written the moment it changes, on a page whose other rows wait for Save, so it says in its ? that it applies at once and to every profile.

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 is refused, 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).

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 makes the press write across it: a grow from a pinned 715pt window inside a 1160pt auto slot writes 765 and trims the row for every neighbour, and the shrink mirror raises 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 is worse: it swallows 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 resizing it by the stored number has two symptoms: an oversize configured slot (set at the desk, applied on the laptop) makes shrink presses move an invisible number for several clicks before anything responds, and a window pinned by its learned bound lets presses silently resize every NEIGHBOR — grow walks the store up through the row until it catches the pinned span and only then says “maximum reached” (owner device QA). 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, replacing #1056’s interval acceleration). Re-firing the binding on a shrinking timer feels chunky on device for a reason no constant can fix: the repeat engine decides only when to fire, never how much, because the amount lives inside opaque Lua — so acceleration shortens the gaps and leaves 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 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. Absolute points (the issue’s proposal) are rejected 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. Re-running the whole body per tick (#1056’s shape) would at frame rate 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.

The tiled paths have that by construction; the floating one needed #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 can trust without a record of its own is 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 falls back to the echo-fed frame, and at glide rate most frames re-base 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 has 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 is not the record but the bound: the #881 instant stamp is rejected here (#1056) 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 — 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. The instant tiled writes are only the tiled half of that answer; the floating half is where Reduce Motion did the damage — an accessibility setting quietly degrading a headline behaviour (#1090).

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. The two commanded bases a floating write accumulates against (#129/#1090) each carry their own bound: 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. (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. A fit dividing the span by min_window_size alone opens as many tracks for a row of Xcode, System Settings and an Electron app with a minWidth as 300 pt windows would, and they overlap (#1355). The derivation takes the largest prefix whose tracks — each at the larger of Track’s own 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, designed for: the learned minimum arrives lazily, so a fresh space may settle one retile later, 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 unlearned answer 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) has 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 track draws no wider than its learned ceiling, a refusal names a share the screen draws, and a switch re-issues without probing (#1488). A share a track’s members cannot draw is empty screen: a re-share that water-fills floors only leaves a fixed-size window’s track unpinned, taking the whole remainder while the column beside it sits at its floor. flooredWeights therefore clamps every share between its floor and its members’ corroborated ceiling (maxWidth/maxHeight, #1055’s, read through learnedFloor’s mirror and the one trackCeiling; a track is ceilinged only where every member is) at the one water level that fills the span — the answer the floor-only pinning already computes. Two edges are ruled: a ceiling-pinned share keeps the shave’s margin, since the render’s cascade check reads min_window_size exactly and a fixed-size window narrower than it would otherwise pin its track AT the floor; and ceilings that together cannot fill the span bind nothing, because the render fills the span whatever the weights say — the gap lands inside the slots and the floors still heal, rather than the overlap standing for want of a fill. The clamp owes the same ceiling ahead of the write: a grow at it is refused with the own-maximum pill, scrolling’s rule, since an admitted write would land and the next retile’s heal un-write it, wordless — while a step that crosses the ceiling lands and the heal is its clamp. Second, a refusal must name a share the screen draws: a clamp folding on the per-marker partition while the render folds on the geometric cap says “the neighbour cannot shrink” of a partition nobody sees. The render, the heal and the clamp take the one renderPartition — the #944 rule, with the list the cap reads and the list the fold reads assembled once. Third, re-issue and probe are two intents and a switch asks only one: a single force carrying both hands a switch — which needs the re-issue, since its echoes lag and strand windows — the probe as well, under which the count and every heal stand down. RetilePass spells the choice at each site — .apply for an explicit set_* apply, where re-asking is what the user asked for; .reissue for a Space or Desktop activation, its settle, and a drag that activates a Space — and RetilePassRoutingTests is the one census of who chooses what, since a wrong choice compiles and draws. (TrackCeilingHealTests, TrackResizeFoldTests, SpaceSwitchReissueTests, RetileBoundSkipTests ▸ reissueIssuesTheBound)

[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 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 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: 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. A one-shot write at the confirmation edge (the issue’s first proposal) is 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, re-sharing its weights at retile already (#1355). 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): 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 save 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 stays 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 draws 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] Anchoring FloatResize at the origin moves 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 stops 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 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, because 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 moves a window, so one larger than the space between two bars would be pushed to one side and still overflow 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 has its outer sliver hidden; flush against a screen edge it is clipped instead. Insetting at bars only (device QA) 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.

Painted chrome counts only on an edge the layout reserves: a bar set not to reserve its strip keeps no float out, as it keeps no tiled window out (Reserved where a bar draws, #1524). The same holds for the floating gather’s strips and for the drop fit’s lift clear of a bottom bar: both carve or clear only a strip on a reserved edge.

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 window filling its whole screen is presenting, and the float nets leave it where its app put it (#1787). A slide show, or a borderless-fullscreen game or player, draws over the menu bar, the Dock and the shelf on purpose — that is the point of it — so a fit pushing it clear of a strip, or a floating-mode entry laying it into the quit grid, breaks the thing the user just started, as either would break native fullscreen, which both already skip. The verdict has two inputs: the frame, edge for edge with a whole screen, within 2 pt, over every screen’s WHOLE frame — never larger, which is an oversized float the fit still owes — and the window’s AX subrole, below. The fit’s skip is load-bearing twice: its frame write also stamps the #1161 placement ledger, and in a Scrolling Space a live entry is the whole bounce verdict, so without the skip the show’s own first focus reads as an app answering our placement and the distrust raises the editor back over the show.

The subrole is read at adoption and refreshed with the fullscreen state on every reconcile, and a move or resize that crosses into covering a screen asks the app again, since a game may drop its title bar in place as it goes borderless. A titled window normally stops at the menu bar, but with the menu bar set to auto-hide and no Dock on that screen a float maximised by Fill matches the screen too. Its title bar is what tells it from a show — it reads AXStandardWindow whatever its frame, while PowerPoint’s show reads AXUnknown (device, 2026-10-11) and so does Firefox’s non-native fullscreen window (the AX corpus) — so the frame and mayPresent are composed once, in coversAsShow (#1788). A subrole the read could not answer keeps the frame verdict, and a titled window whose app reports another subrole still reads as a show. The verbs native fullscreen’s own exemption skips — the inactive-Space stash, focus stepping, the sticky carry — stay on it alone: a show lives on the Desktop the user sees, so it parks with its Space, is stepped to and travels when sticky (owner, 2026-10-11). Every other reader of a window is ruled against the verdict one verb at a time (#1788), through the one presents door, which asks an effective float alone — a gapless layout sizes a tiled slot to its screen, and that is no show. Ruled so far: the focus ring and the on-window marks stand down over a show as they do over native fullscreen, since a ring would draw at its corners and a mark over the slide; and the #1161 placement distrust never bounces a show’s focus, judged on the window’s frame and on the placement both — the stash restore, a placement verb and the strand recovery all write a show’s frame back, and the restore’s echo may trail the show’s own focus. One predicate in the distrust closes the class where a skip per writer would each have to be remembered. A float growing into a show — a game toggling borderless, a show animating open past its focus — retiles nothing, so the cover crossing re-reads the ring and the marks beside the bars.

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): 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).

The same press record decides a MOVE that arrives after the release (#1798): AX throttles moves, so a flick into a bar strip can report its only move once the button is up, and a drag pipeline that asked for a held button never dropped it, so the bar clamp never ran. A move that keeps the window’s size, on a float, after a single press inside it released under a second ago is that flick’s drop. Floats only (owner ruling): the late gesture runs the drop’s clamp and never a tile’s swap, asks no Space Bar item (the pointer has moved on since the release), and claims no later resize, so a zoom stays this entry’s to correct. The cost accepted: an app that moves its own float, size unchanged, within a second of a click inside it gets the drop’s clamp and re-file, which only keeps it clear of the bars (FloatFlickDropTests).

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 is a loss KiwiDesk would cause itself: a restore pass sweeping the capture of a window that merely vanished with its Desktop while the away ledger (#1146) still knows it centres what could have been restored. The sweep 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 loses windows behind the visible ones or off the screen, which the owner ruled crucial and frequent. 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: “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) 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. Leaving the visible members in place (the issue’s proposal) fails on device (owner ruling): two untouched columns beside a grid of the rest read as a mess, and the gathered windows lie 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 judges a pending capture rather than the state frame it is leaving, since a fit of the stale frame lands after the delivery and undoes 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 is adopted later into its remembered Space carrying the frame the boot scan tiled it at, on the main display, while its Space is 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. One reader #1286’s sweep leaves on the flag: 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. This answers ARRIVALS; a hand DROP on another display re-files every float, flag or floating-mode, at the drop (#1686, below beside #492).

A float dropped past the bottom is shrunk to fit, never moved back (#1427, owner ruling 2026-10-06). [Principle] A window pushed down until its bottom passes the usable area — the screen edge, or the top of a bar on that edge, less the focus ring’s reach as every float placement keeps it (floatGrowBounds; the outer gap is a layout reservation and is not followed) — keeps its top edge where it was dropped and loses height from the bottom. Moving it back up would undo the move the user just made. A clipped strip below the border is not a position anyone chose, so this is a narrower reading of #1091’s “a float’s position is the user’s”, not an exception to it. Three limits keep it narrow:

  • It runs on a hand drop only, never from a retile net, so a float parked half off-screen by any other means stays where it is.
  • It stops at the window’s minimum height (the configured minimum, or a corroborated app minimum). Past that, at the screen edge the rest stays clipped rather than the window moving; under a bar on that edge the bar still lifts it clear, since a bar reserves its edge for every window (#242).
  • A window whose top is already past the border is left alone: its title bar is out of reach. A bottom “peek” with the title bar showing is therefore reachable only at the window’s minimum height.

Floating a tiled window centres it at a derived size (#1674). [Principle] The frame a window has when an explicit float verb fires — make_floating, or a toggle_floating that lands on floating — is the layout’s SLOT, which the user never chose. So #1091’s “a float’s position is the user’s” has nothing to protect at that moment, and holds again from the moment it lands. Keeping the slot’s frame left a float taken from a narrow stack slot or a full-height column in exactly that awkward shape, to be resized by hand every time, and a shove of a few points only acknowledges the flip. A real move acknowledges it and is useful too.

The size is measured on the region’s short and long axes, not on width and height, so one rule serves landscape and portrait: two thirds of the short axis, and a third of the long axis floored at 800 pt — a third of a laptop is narrower than most apps draw usefully, and at 600 pt a browser still takes its compact layout — and capped at 1.25 × the short-axis span, since a third of an ultrawide is a banner. A landscape screen gets a tall window, a portrait one a wide one. The region is floatGrowBounds, a placement nothing else will correct, and a corroborated app minimum from the size-bound ledger outranks the derived size and a corroborated maximum caps it, so the window is centred on the size it can take. The placement then forgets the window’s size-bound ledger, as the traveler re-home does: its resize is no layout ask, and read against the last tiled ask it teaches the learner the float’s size as the app’s bound, so the next tiled space draws the window that small. The numbers live in FloatPlacement and are the owner’s to retune.

A re-float returns to its last floating frame (#1675). The derived size answers a window with no floating frame of its own; one that was floating a moment ago has one the user chose, so tiling it remembers that frame and floating it again consumes it — where it still lies on the screen of the Space it floats onto, since a frame from another screen is the #502 crossing’s question and not this one’s; it is shrunk into the float region there, never under a learned app minimum, and a parked corner frame is never recorded (#1352). It is recorded only where the flip really tiles the window: a floating-mode member made tiled still floats, so it records nothing. The memory is SESSION state by owner ruling: it lives beside the float overrides, never in a snapshot, and ends where the window ends — a close, an away window’s retirement, its app’s exit — while a minimize, a hide or a Desktop departure, which come back under the same id, keep it. So it is bounded by the live windows rather than by uptime, and a restart starts empty with nothing on disk to prune. Persisting it was refused as overkill — a reopened window rarely carries the same id, title or screen.

Who is placed. The verb is ruled onto EffectiveFloat, judged on the space the window RENDERS on — a sticky traveler’s, not its home’s, for the gate and the region alike: a window already an effective float there — its flag, or a floating-mode member whose frame is the user’s — keeps it. App rules floating a window at creation stay out, since the frame it opens with is its app’s, as does a Space’s own entry into floating mode, which #1177’s quit grid owns. A rule saved over a window already TILED, or a detection misread healing into a float, is placed like the verb (#1820): the frame that window brings is a layout slot, which is what the placement exists to replace.

A move into a floating Space is placed the same way (#1708). A tiled window a move verb files into a floating-mode Space — move_to_space, a Space Bar drop, a Desktop move that re-files it — brings the same layout slot, and from a scrolling row that is often a scrolled-out column’s, partly off the screen. So the filing places it as the float verbs do, delivered through the stash seed so an unshown Space receives it at its activation. It is not the #1177 gather: the user chose the destination and the Space’s other floats are theirs, so only the arriving window moves. A window that was already an effective float keeps its frame and takes the #502 crossing, and a dragged one keeps the pointer’s placement. A sticky window keeps the re-anchor: the only sticky the filing reaches is a display sticky crossing displays (#445 refuses the rest), and it never parks, so a seeded placement would wait for a delivery that never comes while the window stays visible on the wrong screen.

A centred placement cascades. Several windows floated or moved in one after another would all land on the same frame, a pile #1177 reads as unreachable. So where another effective float drawn on that Space shares the centred frame’s centre, within half a step, or would pile with it by #1177’s own containment test in either direction — a smaller window centred on a larger one sits inside it, which a centre test alone steps past without leaving — the new one steps down and right by a fixed step until the spot is free, the float verbs and the move alike (owner ruling 2026-09-27). A float the walk cannot leave inside the region — a near-maximised one contains every step — must not switch the cascade off for the rest, so where no step fits, the shared centre alone decides; where even that finds none, the centred frame stands, a pile accepted rather than pushed off the screen. The floats counted are every one the Space will draw where the window lands: its own members, the travelers it draws now, and, for a Space not yet shown, every all-Spaces window that arrives with the switch. A remembered frame (#1675) never cascades, because it is the user’s.

The knob. set_float_placement("center" | "keep"), Lua-only like the other float polish knobs (#502); keep is the exact frame with no nudge, since whoever asks for it asked for no motion. An enum rather than a boolean so a later placement joins it without another rename. The retired float_nudge crossed by migration: a stored false was a choice and became keep; a stored true was written by every save under the old default, so it records a save rather than a choice and was dropped onto the new default (the #1255 reading, owner ruling 2026-09-26). An init.lua call is the user’s script and is not migrated (the ConfigMigration carve-out): set_float_nudge joins APIReference.retired, so the call fails naming its replacement.

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. A gate asking the flag alone makes the same window, in the same space, under the same shortcut, resize or beep 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 excluding flag-floats from the unfocused set (#278) had no argument behind it, 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): 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, lifts 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). 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.

A window is tiled or floating, and Tile hands it back to the rules (#1810). [Principle] The automatic verdict — float rules, dialog, sheet, panel and raised-layer detection, an app with no Dock icon — decides whether a window floats, and the user can add exactly one thing on top of it: “I floated this”. Float (make_floating, the bar menu’s Float Window, toggle_floating towards floating) records it; Tile (make_tiled, Tile Window, toggle_floating towards tiled) clears it, and the window follows the verdict again, including a rule added afterwards. There is no manual-tile override and so no third “auto” state to return to: make_auto is retired with no alias, since Tile does its job. Do not bring a tile override back:

  • It was never designed. make_tiled shipped as “un-float”, and only a later change made it a sticky override.
  • No report ever used it to correct a misdetection.
  • It was an invisible third state: it beat every rule, survived close and reopen, nothing drew it, and only Lua or the CLI could clear it. With Tile Window one click away on the bar menu, a window tiled once would never obey a float rule added later.
  • A Tile pressed during a momentary misread mid-launch stuck after detection healed.
  • Its one real beneficiary, an app that hides its Dock icon, is better served by an app-level rule: the Tile rule (#2173).

Tile on a window the verdict floats refuses. Overriding the verdict there is the third state again, so the verb does nothing and says why, in one sentence per reason — a rule, a dialog or panel, an app with no Dock icon — carried as a reason case that one Core renderer turns into words (AutoFloatReason+Rendering: the pill and the menu are Core-drawn, so no word crosses the #96 seam), and CLI/IPC fail in English. The bar menu’s Tile Window row greys with the same sentence rather than hiding (grey, don’t hide), and the row and the cue read the one reason value so they cannot disagree. A rule’s reason names App Rules, because that is where the user can change the answer — and so does a guess’s, which says “floats automatically” where a dialog says “always”: the word tells which floats have an escape (#2173). The label names the first obstacle met, so a fixed-size window a Float rule also matches names its size first and, once a Tile rule tiles the guess, the rule: two steps, each one true.

Float records nothing where the verdict already floats. Such a record changes nothing now, and later keeps the window floating after the user removes the rule that floated it — the hidden state by the other door. So the rule holds from every door, not only the verb’s: a record made earlier is dropped the moment detection floats the window, and neither the reopen memory nor a restart restores one onto a window detection floats.

The verdict has one reader. A verb and the menu row ask it through KiwiCore.tileRefusal(of:), which reads EventLoop.detectionVerdict(for:), the copy detection wrote, never by calling the float detection or the rules again beside it, which would let the verb and the window disagree. A floating-mode Space still floats every member through EffectiveFloat; that is the Space’s layout, not a verdict about the window, and Tile does not reach it.

A raised layer is judged when the window is adopted (#2572). A standard window that floats only because it sits above the normal window level — a picture-in-picture video, a mini player — has its own reason, raised_layer, apart from a dialog or panel’s, and shares the panel’s refusal sentence, since neither has a setting to change. A later re-check never floats a tiled window on that reason — Brave lifted its main window to the prompt’s level each time it came forward with a site permission prompt open (2026-10-23), and the window floated and shrank on every lift. A Float rule that comes to match a tiled window while it is lifted floats it at the next re-check after the level drops. One way only: a window adopted floating keeps floating on that reason, or an app that misreports its subrole mid-launch would tile its panel once the subrole heals. The price is a window an app raises after KiwiDesk tiled it — a “float on top” toggle, or a level set a moment after the window opens: it stays tiled until it reopens or KiwiDesk restarts.

A grid’s track sizes belong to positions, and outlive the column count (#763). [Trade-off] A grid stores one unnormalised weight per column index and per row index, 1 an equal share and absent meaning all equal; the render applies the first n and draws a track past the list at 1. Three things fall out, and each is what breaks without it. Per window is impossible: a grid fills in arrangement order, so a size that followed its window would re-cut every column whenever one closed — a size belongs to the place, like a spreadsheet’s column width, and a track can only leave from the end. Truncating the list when the count drops would make opening and closing one window a lossy round trip, and an auto-size grid crossing a screen change the same; a writer therefore never shortens it. Applying weights only to a rigid grid is cheaper and defensible, but makes one drag work or not depending on a setting the user set once and forgot. A resize moves the edge between two adjacent tracks: the neighbour gives exactly what the track takes and every other track keeps the length it drew — the bsp arm’s shared edge, per index, and where no floor lifts a track simply the pair’s weight sum held. The press solves against the render’s own floors, since a lifted track’s length is not proportional to its weight and a split by drawn lengths would move a third track. Since weights are shares, the store is bounded by construction and needs no ceiling (the scrolling slot, an absolute length, is the one store that does). The floor is the render’s: a track is lifted to Grid’s min_window_size and the learned minimum of every window alone in it, taking the room from the others by weight, rather than rewriting the stored weights at retile — a heal that wrote them would trade the user’s proportions for one screen’s arithmetic and keep the smaller screen’s answer after a re-dock. Under fill_empty_cells the short last row keeps the grid’s columns and its last cell spans the rest, so the columns line up in every row; re-dividing the short row would give its cells widths no column carries.

Maximize is one window for a while, not a layout (#2448)

Section titled “Maximize is one window for a while, not a layout (#2448)”

Maximize fills the screen with one tiled window, without a macOS full-screen Desktop, and ends on two predicates rather than a list of events. The green button enters a full-screen Desktop (#670) and its Option-click is the app’s own zoom, which #1358 undoes on a tiled window, so Maximize is KiwiDesk’s verb and frame: the screen’s visible frame, gaps 0, the bars, the ring and the marks stood down on that screen. KiwiDesk cannot hide the menu bar or the Dock for another app’s window, which is why the frame stops between them — Apple’s Window ▸ Fill frame.

Switching the Space to Monocle with the bars hidden was the other candidate and is refused: it retiles and parks every window on the Space, needs a remembered previous layout, shows in the Space Bar and Settings as a layout change, and re-anchors Scrolling and Stack. Maximize is a per-Space flag (Space.maximized) laid over the frames the retile ISSUES: the other windows stay where they are, the slots every reader classifies against are unchanged, and the layout keeps reserving the bar strip, so nothing reflows on return.

It ends when focus lands on another TILED window of its Space — a focus key included, so focus-right puts it back rather than moving the maximize to the neighbour, which would be Monocle — or when the window leaves the tiled set. A float taking focus (a dialog, a palette) and a switch away and back keep it, since the state is the window’s, as macOS full screen, AeroSpace’s fullscreen and Rectangle’s restore all keep theirs. A verb that rearranges the Space ends it first and then acts, rather than refusing: a swap or resize of a slot nobody can see would be a lie, and a refusal a dead key. The end is made at the write, so a verb that refuses ends nothing, and a drag puts the window back without a drop, since its gesture began at a frame that is no slot. A Desktop switch is no close (#1207): the Space goes on naming the departed window and the return finds it maximized, while a close or a minimize ends it. A profile change keeps no rule of its own: a maximize ends where the new arrangement changes its Space’s layout or files the window elsewhere, and stands where both stay. A sticky window is refused, since it draws on other Spaces’ layouts too. It survives KiwiDesk’s own in-place restart and not a Quit. Its motion is the state-change animation (a window changing state). Named “Maximize”: “Zoom” and “Fill” already mean other things in KiwiDesk’s catalogs, and “Full Screen” promises a new Desktop.

The pocket is one slot beside the Spaces, not a Space (#2446)

Section titled “The pocket is one slot beside the Spaces, not a Space (#2446)”

[Rationale]

One window, three states — empty, pocketed, out — and the window is never a member of a Space while it is pocketed. The slot (StateCoordinator.pocket) knows the window, and the window stays tracked, so its frame, its app and its focus reports keep flowing; but no Space lists it. That is the away ledger’s shape (#1146) for the same reason: a member with a “pocketed” flag would owe an exclusion in every reader of a Space’s windows — the layouts, the bars, navigation, KiwiVision, the quit gather — and the one reader that forgot it would draw the window back. Outside every Space, a reader of a Space’s members that forgets the pocket leaves the window hidden, which is the failure a user can recover from with the key. That covers only the Space readers: a walk over every tracked window — the stash park, the marks, the Desktop carry, the window switcher, get_state — reaches the window in both states, so each such walk rules on it, and forgetting it there draws a mark on a parked window or parks it on the wrong screen.

The slot is an ordered list (#2678), amending “one window”. The pocket holds one slot per marked window, in the order each last went in, and a slot keeps its place while its window is out; of the keys, only toggle_pocket_window removes one (a close, a minimize or the app’s exit ends it too). pocket puts away before it takes out: while any marked window is out it puts back the focused one, else the one focused last, a take-out counting as a focus; only with every window in does it take one out, the one put in last. The deciding fact is whether a pocket window is on screen, which the user sees rather than remembers, and with one window the key is #2675’s toggle. “Focused last” is the window switcher’s own focus recency (#1840), not a second record of it. One window stays the default, so a second mark is refused until the Several windows setting opens the list.

Choosing the window is its own key (#2675), amending #2446, which chose the window with the one key that pockets it. Becoming the pocket window is the one resize. With one key, the first press on an empty pocket chose the window and every press after it only toggled that window, so nothing short of closing the window or a quit let a user pick another. So toggle_pocket_window (⌃⌥⇧P) marks the focused window and unmarks it, and pocket (⌃⌥P) pockets and takes out the marked window alone. A second window is refused with a pill that sends the user to the marked window, rather than silently moving the mark: the pocket window carries a frame per screen and is the one window a key always reaches, so taking that away is an act on the marked window, not a side effect of pressing a key somewhere else. Marking floats the window at once at the float placement — a window already floating keeps its frame, since its size is one the user chose — and leaves it out: a marked window is the pocket window, and an out pocket window is a global sticky float, so a mark that left it tiled would be a fourth state. That is the only time the pocket sizes the window: a take-out restores the frame filed when it was put back, so a size the user gave it holds. With nothing marked, ⌃⌥P still marks the focused window — sized as a mark sizes it — and pockets it, so the single key keeps working for someone who never learns the second. The verb acts on the focus, which a pocketed window never holds, so unmarking happens while the window is out. ⌃⌥⇧P sits beside ⌃⌥P as its related variant — the two keys act on one window (the ⇧ entry below, as amended). Unmarking tiles the window on the Space shown (owner, #2680): leaving the pocket is a return to the layout, so it ends where a Tile ends it, and a window a float rule or detection floats stays a float.

A pocketed window waits on one screen, the active Space’s (StateCoordinator.pocketHomeDisplay), and the park and the Desktop carry both read that one answer; two answers would park the window on one screen and carry it to the Desktop of another. Pocketing is a focused-window command and takes the #292 foreground check; taking the window out and putting it back act on no focus, so a filled pocket passes that check.

Out of the pocket it is a global sticky float, borrowed whole. “Comes with me wherever I go” is what the global sticky scope already does — every Space of every screen, carried across macOS Desktops (#1145) — so the out window takes that scope and the float flag rather than a third rendering rule. The pocket window is carried across Desktops in both states whatever sticky.set_desktop_reach says, since a pocketed window left on another Desktop is one the key can no longer reach. It follows that the sticky verbs, and the float verbs that would tile it (make_tiled, toggle_floating), are refused on it with their own pills: their scope is the pocket’s, and letting make_tiled or make_unsticky through would leave a window the pocket still claims tiled, or held to one Space, which is neither state. make_floating asks for what the window already is and succeeds without a pill.

Not minimize, not ⌘H, not a Space. Minimize animates into the Dock, changes the Dock’s state, and drops the window from KiwiDesk’s state, so the window and everything known about it would leave together; un-minimizing also lands it back where it was, not where you are. ⌘H hides every window of the app, and a hidden app’s windows leave tracking (#913). A dedicated scratch Space shows the window by switching you to it, which is the trip the key exists to save. Sticky is the opposite want: always visible rather than visible on call. So the pocketed window is parked off-screen by the same stash that hides any window of an unshown Space.

macOS activating a pocketed window takes it out. ⌘-Tab, a Dock click or a notification hands focus to a window parked off-screen; left there, the user types into a window they cannot see. Taking it out at that report gives the activation the meaning the user meant. The take-out is asked after every distrust the focus handler runs — the ignored-panel re-report, KiwiDesk’s own z-order echo, the accessibility-steal and menu-bar reveal returns — so a report one of them claims is never read as the user’s activation. And a report inside the move latch’s second after pocketing is swallowed, since the window’s own focus report can still arrive after it was pocketed; the trade is that a real ⌘-Tab to the app inside that second leaves the focus on the parked window (Accepted limitations).

The window switcher lists the pocket after the hidden apps. The switcher’s first row is preselected and is the window you were in before, so ⌃⌥J Return keeps meaning “back”; a pocketed window has its own key and would otherwise sit on that row whenever it was the last thing you used. After the hidden apps, it is the one more window a search may want.

A quit leaves no window parked. The pocket does not survive a quit, so a quit files a pocketed window into the active Space as the user’s float, where the quit gather lays it out with the others, and leaves an out window where it is as a plain float — a next launch with no pocket would otherwise hold a window off-screen that no key can bring back. An update’s in-place relaunch carries the slot (StateSnapshot.pocket), since that restart is meant to be invisible.

A window changing state animates under its own switch (#2676)

Section titled “A window changing state animates under its own switch (#2676)”

[Rationale]

Floating and tiling back, maximizing and returning, and going into the pocket and out move under animations.on_state_change, and only the window changing state follows it. These moves are bigger than a drag or a resize step and change what the window is, so a user who wants reflows calm can still watch a window float; its neighbours keep following on_relayout, so turning reflows off never takes this with it. A float the detection places is no verb of the user’s and keeps on_relayout. The window changing state promises a full spring for itself (BatchSizing.allSpringSized): becoming floating shrinks both axes, and a shrinking axis otherwise takes its target on frame 1, leaving only the corner to travel. A slow app’s window trails our spring, so its ring is held dormant until the window has landed rather than running ahead of it.

One toggle, not a choice of styles. No style can capture another app’s window, and none can set its transparency (Blocked by macOS (SIP)), so a “fade” is a tinted plate flashing over a jumping window: a glitch on float and maximize, not a style. A Genie stays refused because it means “minimized to the Dock” and needs a private API (the overflow mark’s peek). Pocketing draws a brief pocket rather than moving the real window (#2680, replacing #2676’s slide to the park corner): a pocket in KiwiShelf’s look appears at its placement for the moment a window goes in or out, the Space switch’s plate flies between it and the window — our own overlay, redrawn at each size, never a capture — and it nods and fades. The window parks at once and comes back with one write, held until the plate lands, so a slow app or a minimum size is never in play; no pocket stays on screen under One window (▸ The pocket belongs to KiwiShelf). The bar menus offer Pocket Window and no Take out (owner, 2026-10-27; #2678): a pocketed window has no bar item, so only Pocket could show, and the pocket’s list is where one comes out. The plate’s flight follows animations.duration, the window duration, at twice it within 0.2–0.8 s, since a window’s own trip is what it draws; the fades and the nod take no setting, since they shape how softly the pocket appears, not how fast anything travels (#1931’s own pace ruling the other way round).

[Principle]

A space’s name is its identity; the PROFILE is the scope its settings and contents resolve in. Two profiles may each declare a space called 1, and it is one live space: across a switch between them it stays live and takes the incoming profile’s settings, while each profile’s own record (below) decides which of the windows it knows go there. focus_space 1 takes a bare name, resolved against the profile you are in, the way it already resolves against the Desktop you are on.

Telling two same-named spaces apart by their layout was considered and refused (owner, 2026-09-30, #1790): a layout is a weak signal and often a temporary one, and a split made on it would mint doubles of a space the user thinks of as one. A distinct name is the user’s tool for two spaces.

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 what the space held was not, so two profiles’ 1 shared windows while disagreeing about how to draw them. Giving what the space holds 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. With ensureSpace matching the incoming profile’s spaces onto the live ones by name and pruneSpaces forwarding the rest to the fallback, switching profiles and back merges 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 (#1230).

Each profile 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. The record is a membership, never an order: a window already in its remembered space stays where the live row has it, because on a Desktop switch between bound profiles the restore runs while the departing Desktop’s windows are still on screen, and re-placing them re-ordered the row the return then rebuilt (#1387).

The record rides the session snapshot, so a profile that is not live keeps it across a KiwiDesk quit, update or crash; it goes where the snapshot goes, which a Mac restart or a logout ends: the login-session gate refuses another login’s file, records and all, and no restore across a restart re-keys a record (#1802, #1385).

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 space goes with windows still in it — delete_space, or a switch away from a hand-written config that has no arrangement to hold it for (Every switch holds what the incoming arrangement does not name) — 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 they prune exactly when they change the profile (#1230) — 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. The Space half is #1230’s: every Desktop keeps its own Space memory, 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’s profile-wide tuning is the main screen’s.

It supersedes #8’s recommendation to turn the option off, which 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 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.

A setting nobody varies per profile is app-wide, and it crosses out of the profiles by ADOPTION at the first apply, not by a migration step (#1741). [Rationale] The alert sound when an action can’t apply (refusal.sound), how windows are spread on quit (quit.layout) and the quit grid’s windows per pile (quit.grid_target_depth) were stored in every profile’s TilingSettings. None answers a question about a desk (owner, 2026-09-28): nobody wants a different alert sound or a different quit arrangement per profile, and the pile depth depends on the quit layout. Per-profile storage bought only a way to lose them — a Desktop binding switching profile silently changed all three. So they live in gui.json’s refusal and quit groups, held on KiwiCore.appWide, and a profile apply never touches them. The Lua verbs keep their names; only where the value is stored moved.

The move changes where a stored value lives, so it owes a one-shot crossing (AGENTS.md §5). N profile files → one value is an election, and a byte-level ConfigMigration step cannot hold it: a step sees one file’s bytes and cannot know which profile is live. That is #1307’s finding, and it stands for steps. What it does not show is that the move is unbuildable — the election can be held where the live profile is known, in two halves. Capture runs first in loadConfig, ahead of the #1530 settle and anything else that may rewrite a profile file: prepareAppWide reads gui.json, its stored values become the running ones, and a file readable without them owes the crossing, so every profile file’s retired values are read into memory there and then. Adoption ends it at the first apply(profile:): the incoming profile is the live one, so its captured values are adopted — a profile that carried none keeps the settled values: the defaults at an upgrade, this Mac’s own at a restore — gui.json is written with them, and only once that write has landed are the retired groups (refusal and quit, whole) stripped from every profile file by a surgical edit that leaves the rest of each file as it was. A write that fails strips nothing, so every profile keeps its copy and the next launch can still cross. The crossing ends in the files, which is what makes it end at all: once the groups are gone, no reader of them is left to retire. The order is the whole design — a settle that re-encoded a profile before the capture would destroy the only copy the election reads. An unreadable gui.json owes nothing, since the crossing ends by writing into it. While the crossing is owed, no gui.json write stamps app-wide values at all, so no unrelated save can end it with values it never adopted.

The trade, each half stated. The first profile applied decides, and every other profile’s copy is abandoned — a user whose profiles disagreed keeps the live one’s values, which is the one the user was hearing and seeing. A built-in Standard is not a stored profile and lends nothing; the crossing waits for the first stored one. A Lua-owned config crosses nothing: init.lua is the store there, and writing a gui.json would hand ownership to a file the user never chose, so loading a Lua-owned config clears any crossing a GUI-managed load had left owed. The cost is that a value a profile file carried is no longer read in such a setup (Accepted limitations), and General’s two rows grey with the reason, since a row there has no store to write. quit.layout has no row while grid is its one value. A backup written before the move crosses the same way: its settings store no app-wide values, but its inline profiles still carry them, and a bundle is the second reader of that shape — so readBackup reads them off the bytes before the decode drops them, and the restore owes them to the first profile it applies. A backup written after carries them in its settings.

A verb changes the running value; only a General row or an adoption stores one. KiwiCore+AppWide holds them apart — what the engine reads, the value no verb has touched, and what gui.json carries — because every gui.json write stamps the app-wide values in. A stamp that took the running value would persist a set_refusal_sound typed at the CLI the next time anything unrelated saved, which is the one thing no set_* verb does. The untouched value is there for the same reason: a row write or an adoption before anything was stored builds on it, since building on the running value would carry a verb’s change into the file along with the row’s. So a verb changes the running value alone, a General row writes gui.json at once, and moving a Lua-owned setup into the GUI (adoptConfigIntoGui) stores the values init.lua executed, since those are what the user was running.

In Settings, the rows sit on General’s “Applies immediately” card and write gui.json at once, with no Save. (#1741, ui-designer ruling.) Every row on that card does what it was just told the moment it is told; a draft-and-Save row there would be the only one that does not. That was #1255’s argument for keeping the alert sound off General, and it still holds — what changed is the storage, which made the immediate write possible. General is on every user’s Home, so the switch now reaches everyone, where Behaviour, then Power-User-only, gave most users the pill and not the switch. With both rows gone, Behaviour held nothing, and the destination is retired rather than kept for an empty card: This Profile on Home now holds exactly the profile-scoped cards, which is what its heading says.

Floating windows hide with their space; visible-everywhere is Sticky, an explicit flag. A floating window exempt from the inactive-space stash, following you across spaces (once blessed as intended “for PIP”), is a bug (#412): 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. 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 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’s verbs read “sticky on this screen”, #1094); 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 reserves no phantom tiled slot while it has traveled away, or the same window fights for two frames across monitors. (#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 #1145’s (Sticky reach spans macOS Desktops). 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. Giving every display the same five spaces — one per layout mode (#466) — shows a newcomer 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 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 — is an ultrawide, and past a 3.0 aspect a super ultrawide (32:9), while a screen taller than wide is a portrait one, a rotated ultrawide included. Both ultrawides lead their tiled layouts with Stack — two mains side by side on 21:9, three on 32:9, equal by the accepted #222 limitation — because the mains keep the work in the middle of the screen and the rest in one column, where Track divides the whole width among whatever is open. BSP exists only in the middle class, producing absurd windows above it and unusable ones below. (Owner ruling, #1662.) 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.

Beside an ultrawide, Scrolling lives on the ultrawide alone. With an ultrawide or super ultrawide among several screens, the budget and lead rules around this paragraph give way: the ultrawides lead Scrolling, every other screen leads Monocle and never scrolls, and each screen gets three spaces ending in its own Floating space — a widescreen Monocle · BSP · Floating, a portrait screen Monocle · Grid (one column of three) · Floating, a laptop Monocle · Grid (two by two) · Floating. A Scrolling row belongs where it has width to run; beside one, the other screen’s job is to hold whole windows. The total is three a screen, capped at ten, and each screen has its own Floating space — the one place the budget and the one-Floating rule above do not bind. (Owner ruling, #1662.) A laptop’s Grid is two by two rather than one row because a laptop alone reaches the same tuning when a Space is switched to Grid, and a two-cell cap piles the third window into the last cell, which reads as a broken Grid; the dynamic grid still lays two windows side by side (#2171).

A two-cell cap now scrolls the third window through the last track rather than piling it (#2541); the laptop Grid stays two by two because it shows four windows where a single row would show two and hide the rest.

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. Best-first — a 2560 pt desktop leading with Grid, an ultrawide with Stack — makes the first thing most new users see 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. That holds on the ladder; beside an ultrawide the lead is per class by ruling (#1662) — the ultrawides Scrolling, every other screen Monocle — the one per-class exception. And Scrolling leads several screens at once, which the no-layout-twice rule carves out 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. 85% of the axis leaves the next window peeking in, and is wide enough that KiwiDesk’s own Settings window — the first thing tiled after onboarding — keeps its preview column at every MacBook’s default resolution, the 13” Air’s 1470 pt included; at the earlier just-under-half it opened cramped, its preview folded away, which read as broken (owner ruling, #1662, replacing #1018’s two-side-by-side picture). An ultrawide takes 30% for three readable columns, the case ScrollSize.auto already documented as wanting an explicit size — centred, and a lone window kept at its slot rather than stretched across 21:9 or 32:9 (#1662). 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.) The one exception is direction: a Scrolling space on a screen facing the other way from the one that tunes Scrolling takes its own screen’s direction through a per-space override, since sideways on a tall screen — or down a wide one — is the mode at its worst (owner ruling, #1662).

One tuning per profile: a layout’s is its screen’s, the rest is the main screen’s or the shipped default’s. TilingSettings is profile-wide, so a laptop beside a 27” has exactly one tuning of each layout to give, and the only question is which screen names it. For a layout the allocator places on ONE screen — Stack, Grid, Track — that question has an answer that is not a guess: the screen it sits on, so a Stack on a portrait secondary puts its stack along the bottom (owner ruling, #1662). Scrolling leads several screens and can be forced onto the narrowest one as a repeat, so it takes the widest screen that LEADS it — the ultrawide wherever one is connected, and past a narrow portrait or laptop main, which leads Monocle, its neighbour. What stays the main screen’s is the minimum window size: every layout’s is seeded from the main screen’s width, not from the screen that layout sits on (the entry below); the gaps stay the shipped default on every screen, since a tighter laptop gap let two neighbouring rings touch and made Glass, the look a first run shows, move windows (owner, 2026-09-28, #1739). It is still one TilingSettings, never a per-display config behind the values 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 with a fixed bsp fallback silently hands 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 resolves to that screen’s own best layout, in the starter’s order — Stack first on the ultrawides — since a preset takes the screen’s shape tuning as the starter does (#1663). Where the hardware genuinely is not knowable — a preset’s Layouts sheet 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.)

A preset is its own choices over the screen’s tuning, and it names the workflow, never the hardware (owner ruling, #1663). A preset declares only what it chose on purpose — Minimalist’s wide gap and centred column — and every other setting comes from the same shape tuning the starter takes, each layout tuned for the screen it sits on in that preset’s plan. So Minimalist on a portrait screen scrolls down rather than across, and a Stack on an ultrawide gets that screen’s several mains. A value a preset carries only because a full TilingSettings has to say something is not a choice, so a preset never declares one: a gap equal to the shape tuning’s own is left undeclared. The merge is one function, so a preset’s Layouts sheet, the apply and the monitor-change fallback cannot come to disagree about what a preset is. Its NAME does not follow the screen — “Developer” is how you work, and the shape is what the preset adapts to; only the Starter is named by its screen. The group of presets for the connected screens says ONCE which shapes they are tuned for, naming every screen in the starter’s own words rather than counting the others — the same for every card in it, so a per-card copy would say nothing about any one preset — and nothing stores it.

There is one Starter preset, and it is for the screens you have. It is titled by its main screen’s class — Laptop, Widescreen, Ultrawide, Super Ultrawide, Portrait, ”+ N” for more screens, and never “Desktop”, which names the Mission Control Desktop a profile can be bound to — and saved under that title, while its identity stays Starter: held spaces and the monitor-change rescale key on it, and a title would change under them the moment a monitor did (owner ruling, #1662). Three presets, one per screen count, plan 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” means. The preset’s summary names none of its rungs for the same reason: a list of modes would be a different sentence on every Mac, so it states the rule and its Layouts sheet shows the modes.

A runtime delete_space stays a success, and names what brings the space back (#1509, owner ruling). init.lua running at every launch and re-creating what it declares is intended, so the delete of a declared space is not an error and a warning on every delete would be noise — but automation that reads success as durable is surprised at the next load. The fact rides data.declared_in, absent rather than empty so a runtime-only delete’s output is byte-identical and the key’s presence is itself the signal, and it names EVERY re-creator — the active profile, the built-in Standard a reload recomposes while no saved profile fits, the script — because a hint naming one and silent on another is a half-truth a script will trust. gui.json is not a fourth: its space list is a mirror of live (#77), which the delete rewrites like every other authoritative prune, so nothing there re-creates the space and a line telling the user to “remove it in Settings” would name a file they never declared anything in. A distinct status is refused: it breaks every consumer that pattern-matches success for a delete that did succeed. The profile and Standard halves are the last apply’s set, read from adoption state like every question about the live profile; the script’s half is a run ledger of what init.lua ASKED for, never a before/after diff of the space set: a reload’s create_space of a space already live changes nothing a diff can see, which is exactly the path the hint exists for.

Minimum window size: per layout, one per window (#2237)

Section titled “Minimum window size: per layout, one per window (#2237)”

[Principle]

Each layout that tiles to a floor keeps its own minimum window size, as a width and a height, and a window always has exactly one. A floor protects an app’s own contents, and what a layout must protect depends on how it divides the screen: Grid on both axes, BSP, Stack and Track along their orientation, Scrolling along its scroll axis. A window’s height can often go lower than its width, too. One shared value has to suit the tightest layout, so it either piles a Grid early or lets a split crush an app.

Six values, always stored, with no fallback behind them. Stack, Grid, Track, BSP, Scrolling and Floating each store a width and a height, written whole; the Settings master “All layouts” is a write to all six, not a seventh value. A stored fallback would make an untouched layout show a value it only inherits, and a later change to the fallback would move it unseen. Monocle stores none: it shows one window at a time and divides nothing. Scrolling applies only the axis it scrolls along — the other spans the layout’s whole area — but stores both, so flipping its direction later finds a value already set. MinWindowSizesParityTests holds the stored fields to the layouts, Monocle exempt with its reason.

One window, one floor. A float is placed by no layout, so every window EffectiveFloat.applies to takes Floating’s, whatever layout its space uses — unless the Space has its own value (below) — and so does the quit grid, which gathers windows from every layout into a grid none of them owns. Without that, a window floated in a Grid space would resize to Grid’s floor and change floors on every move to another space, though nothing about the window changed; and a resize clamp and a placement could judge one window by two floors. For the same reason, switching a space’s layout re-floors nothing: each heal reads its own layout’s stores.

Points only, and no per-screen list. A floor protects contents whose size is absolute, so a percentage — of the layout’s region or of the whole screen — would let a window’s floor change with the screen it is dragged to. The Scrolling slot size keeps its pt, % and count units because it is a target, not a floor. A value per screen fails a laptop that docks at dozens of desks: the list grows with every one, and none of it is visible from the screen in front of you. Screen size enters once, at the seed (below).

A Space may carry its own value, and it outranks its layout’s (#2422). Two Spaces sharing a layout can have different jobs — a Grid that watches servers wants small windows, a Grid that holds work wants the usual floor — so min_window_size_override keys a width and height by Space id, beside gap.override. It replaces whichever value the Space’s windows would otherwise take, a float’s included, so one window still has one floor and the user never has to know which layout value it replaced. Monocle still applies none. The value travels with the Space when the Space moves to another screen; that is the cost the per-screen ruling named, and the row’s ? states it before the move. The quit grid mixes every Space’s windows per screen, so no Space’s value has one grid to apply to, and it keeps Floating’s. The map is config, not session state: a held Space’s renumber and a Space drop leave it alone, as they leave gap.override, because the value belongs to the arrangement that declared the Space and comes back with it.

The starter setup seeds every layout from the main screen’s width ÷ 8, rounded to 10 pt and clamped to 260–460 pt, the same on both axes (#821). 300 pt was right on a 2560 pt screen, generous on a laptop and mean on an ultrawide; the division gives 320 pt at 2560 and 430 pt on a 3440 pt ultrawide, and the clamp keeps a laptop from a floor below 260 pt and a 5120 pt super ultrawide (640 pt by the division) from one above 460. The rounding lands on the Settings stepper’s 10 pt step. One rule replaces the starter’s fixed 420 pt for the ultrawides, so the two never become separate rules. Every layout takes the main screen’s answer rather than its own screen’s: seeded from different screens, the master would open on “Mixed” for a first run, showing a new user a difference they never made.

[Principle]

Sticky promises “always with me”, and a Desktop switch that leaves the ∞ window behind stops that promise at the edge of KiwiDesk’s own Spaces. Where the window-management bridge exists, both scopes 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; a version built on it 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.
  • Only a carried window’s overlays ride the switch. A ring belongs to its window’s Desktop and slides away with it; a carried window’s ring, and every sticky mark, join every Desktop so the carry finds them, and so stand on screen through the slide until the switch handler places them (#2149). Accepted: the carry moves the window under them, and a panel that left with the origin Desktop would strand there.
  • 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 — 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 bridge — an 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, so its vanish is read off the WindowServer instead: a sticky window it still hosts on the Desktop the user just left is one the carry owes a move, and the sweep refuses that vanish for the same budget until the switch handler carries it (#1215). The measurement that chose that reading over the on-screen census — which drops the departing windows within tens of milliseconds of the app’s destroy, on either side of it — is on the issue. What remains is a bounded wait for a sticky window closed mid-switch, an accepted limitation, stated there.

Opening an app follows its window into its rule’s Space (#1599)

Section titled “Opening an app follows its window into its rule’s Space (#1599)”

[Principle]

An app rule files a new window in its Space, and without a follow the user stays where they were: they asked for the app and it opened somewhere they could not see. Opening an app is a request to use it, so its window arriving out of sight is wrong rather than merely surprising, and the arrival follows it — a whole Space switch with the focus, followSwitch’s shape, because either half alone leaves the user hunting, and because no native switch rides along to settle what the switch dropped (#1007’s hand-off leans on one). A move WITHOUT follow is the opposite case: it acts on a window the user is already looking at, so its arrival must not take them along, which is what MoveIntentLatch enforces.

The discrimination is the feature, and following the wrong arrivals is worse than following none. The windows macOS reopens at login, the ones KiwiDesk adopts at boot, the ones a Desktop switch reveals and a new tab are all window creations with an app rule behind them, and none of them is the user opening the app. What separates them, measured on device, is an OPEN the user caused:

  • A press within a second. A Dock click activated the launching app 0.29 s after the press and a Spotlight Return 0.35 s after it; a login restore’s activation came 15 s after the password, and a scripted launch 17 s or more after the last press. The read is the HID state’s last click or key-down, which needs no permission.
  • Or a press just before the process started. A cold start activates late — after a purge, Xcode came forward 1.39 s after the Spotlight Return, VS Code 3.70 s and Word 7.75 s — while each process started within 0.21 s of the press (#2201). So a press at most a second BEFORE the process started is the launch’s cause, and the process is opening by construction — for an activation up to 15 s after the start, twice Word’s measured 7.58 s; past that, an app activating itself is its own act, as a press alone is not a cause. The grace is keyed to the START rather than widened: a wider press grace would take the user along to any app activating seconds after any key press, and would widen #2187’s app-act motion with it; Xcode relaunching itself after its install prompt started 5.18 s after the last press and stays put. A press from before KiwiDesk began managing windows never counts — the login password precedes every restored app. A press KiwiDesk does see, such as the unlock password, can still precede a helper’s launch; the 15 s ceiling bounds that.
  • An app with nothing showing. A press alone is not a cause — anyone typing has pressed a key in the last second, and an app that brings itself forward then, or a plain switch into a running app, would take the user along to wherever its rule points. So the app must be OPENING: a process that just started (measured at most 0.3 s before its activation), or a running app showing no window at all — a reopen after its last window closed, an un-minimize. Reopen and restore are opens by owner ruling (2026-09-23): the rule already moves those windows to its Space, and moving a window without the user is the worst of the three outcomes — it vanishes from where they are. A running app still showing a window — a switch into it, ⌘T — owes no debt. The boot scan involves no activation at all. New Window owes it outright (#1518, owner ruling 2026-09-30): the verb names the app and asks for a window, which is the open this rule follows, whatever the app already shows — and a press the app answers with nothing retires the debt, since nothing opened.
  • Either order. A reopen can show its window BEFORE macOS reports the app active (Telegram, on device), so a rule-placed window arriving with nothing owed is kept for the activation that follows, which pays it — only where the window arrived AFTER the press, so an older window is never mistaken for the one the press opened.
  • No Desktop switch around it. The arriving Desktop’s app activates 0.3–0.9 s after a switch, inside the press grace when the switch was a key press, so an activation inside twice the switch settle owes nothing, and a switch retires a standing debt.
  • Open or Focus owes it outright where it opens something. The verb is the user naming the app, so a launch — or a pull of a running app with nothing up — owes the follow whatever the press timing (from the CLI there is none), and the app’s own activation that follows keeps the debt rather than retiring it, since it is that launch completing. A pull of an app already showing a window opens nothing, so it owes nothing.

The debt is keyed by the app’s bundle id and paid by its window’s ARRIVAL, never at the activation, because the window does not exist yet — and Open or Focus owes it before the process does. It is paid once and retired by ANOTHER app’s activation (the user moved on), and it is bounded at the follow’s own bound PLUS one adoption-heal period: a fresh launch’s first window is routinely adopted by the heal rather than its create notification, and was measured landing 6.3 s after the activation. Only the rule’s own verdict pays it: a window with a remembered Space, one re-homed to the screen it landed on, or one the rule files in the Space the user is already in is no launch to follow, and a transient overlay (a splash, a panel) never spends the debt, so the window behind it still can.

A new window follows too, owed nothing (#2478). A running app’s new window — ⌘N, File ▸ New, or one the app opens itself — that its rule files into a Space nobody is showing takes the user along the same way (owner ruling 2026-10-20). Left behind, it waits out of sight, and the focus distrusts that met its first report (#1161’s placement bounce, #465’s sibling re-report) fired for reasons false here.

What makes it new is WindowServer’s word, not KiwiDesk’s. .windowCreated is also a boot sweep, an unlock’s heal, a re-track after a dropped removal and a Desktop reveal, and no guard on those paths would stay complete as passes are added. So the arm follows only a window WindowServer reported CREATED while KiwiDesk watched (WindowCreateLedger, fed by the #1877 notification), and where that route is absent the arm is off and only an open follows. The create can trail the arrival — 8–10 ms behind TextEdit’s ⌘N, measured — so a create landing within half a second of its arrival pays the follow then, as a whole switch.

A login restore is created while KiwiDesk watches too, so the window must also come from a process older than the cold-start grace, or follow a press within a second since KiwiDesk started (owner ruling 2026-10-22): a login restore has neither, a ⌘N right after a launch has the press. The arm stands down where the open arm does — during boot and a fresh Desktop switch — and while another app’s launch debt stands, whose window would otherwise open in the Space this switch left. A popup still opens where the user is (#1602, #2475). The trade, accepted: a window an app opens with no press behind it, a call window, takes the user to its rule’s Space.

That does not reverse the discrimination above. The open arm judges an activation, which says nothing about any window; this arm judges a window WindowServer just created, one the user will look for. So a press — or none, from an app past its cold start — is enough here and still not there.

There is no setting and no same-screen narrowing. A user who pins an app to a Space has already said where they want it, a per-app follow flag is a third rule the App Rules lists would have to carry, and a follow onto another screen is the same request — the window went there, and the focus goes with it. A setting is owed only when a user asks for one.

[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). A window returning from a CLOSE is the other case: it is a new window, placed where the user is and taking the focus itself (the #1414/#1561 entry under the placement bounce).

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. Writing the memory in the switch handler assumes the switch notification precedes the reconcile burst (#40), and the device disproves it: an app whose own Accessibility observer reports fast folds its windows as destroys before the notification arrives, so the handler reads a focus the walk has already moved and the return pays 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 would 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]

Knowing only the windows it can see — a window on a macOS Desktop no screen shows evicted from state at the switch — leaves everything downstream guessing: 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 — a Space holds its windows wherever macOS is showing them — reads as a lie on device (#1146): 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 is 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 keeps 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).

KiwiDesk’s own Desktop switch names where it landed (#2142)

Section titled “KiwiDesk’s own Desktop switch names where it landed (#2142)”

[Principle]

A Desktop switch KiwiDesk performs shows a plate on the screen that switched: the Desktop’s Mission Control number, a dot per Desktop on that screen, and the profile a binding loaded where one did. A swipe or Mission Control switch shows nothing.

The instant switch (#1023) removed macOS’s slide, and the slide was the only thing that said which Desktop came up; on a desk with several Desktops of similar windows the screen just changes. The plate replaces exactly that cue, so it answers only where the cue went missing — a gesture or Mission Control still animates, and a plate over that would say the same thing twice.

The number is Mission Control’s global one, the label Mission Control draws and the number focus_desktop and the Shortcuts rows take; a per-screen position would give one Desktop two numbers in one app. The dots carry the position on that screen, and shape alone marks the current one, so colour carries nothing.

The profile line shows only when the switch changed the live profile. A Desktop with no binding loads nothing, and naming the profile that merely stayed would be noise on every press.

Plain system glass, not the profile’s look, so it reads on any wallpaper and any look with no contrast check per look; Reduce transparency draws it opaque, Reduce Motion drops the fade.

Amended with #1295: under Tint KiwiHub and plates the plate wears the profile’s look (#2569, owner ruling 2026-10-24). It takes the Fill on .clear glass and the look’s inks, with KiwiHub (▸ the shortcuts panel entry). The contrast check per look that plain glass avoided is now computed for every Fill instead of skipped: the reading floor stops the fade where the plate’s worst ink still reads (▸ Liquid Glass, Reading surfaces fade above a floor). Where it does not wear the Fill it is the plain system glass above. The layout plate (#2144) and the Space plate (#2524) are this plate’s panel and follow the same rule.

On by default, app-wide. This is the “uninvited surface” objection the boot notice answers by staying away (#1715), and here it lands the other way: the user’s own key press asked for the switch, and the plate stands in for a cue the app took away. Nobody varies it per profile, so it is app-wide (#1741).

What was not shipped: a number alone (no sense of where among how many), dots alone (counted, not read, past about five), a pill by KiwiShelf (hidden whenever the shelf is), a mini-map of the screens, and the KiwiDesk Space.

A Space switch on Show the Space names its own Space with the same plate (#2524).

Cycling layouts shows the cycle, on its own switch (#2144)

Section titled “Cycling layouts shows the cycle, on its own switch (#2144)”

[Principle]

cycle_mode shows #2142’s plate with the user’s cycle list as a ring — first entry at 12 o’clock, clockwise in Next order — the current layout large in the centre, its slot empty. Only the verb shows it; set_mode and the Layout menu do not.

With three windows, bsp and grid or stack and track can look alike, so the rearranged windows say that something changed and not what. The ring IS the list the key walks, so the plate also teaches the order, and a second press visibly steps around it.

The step is one shared element per symbol: the incoming layout flies from its slot to the centre and grows while the outgoing one flies back to its own slot, in one critically damped spring. It shows which layout came in and where the old one went; a symbol morph in place says only the first. The first appearance stays instant, as #2142’s, and Reduce Motion jumps.

No strip of options and no circling windows. The plate takes no mouse, so a row of layouts reads as a menu it cannot honour; and the frame animation already moves each window from its old frame to its new one, which is the honest motion — a circular path lengthens the change and brings back the AX-write stalls #1956 fought.

Its own switch, not a shared “switch cue”. The Desktop cue stands in for motion the instant switch removed; this one confirms the user’s own press. A user may want one and not the other, and merging them would have owed a ConfigMigration of desktop.cue only to merge two choices. On by default and app-wide, for #2142’s reasons. It is stored as mode.cue beside the other app-wide groups (desktop.cue, refusal.sound), while mode_cycle stays a flat key: the list is the global base a profile overrides, the switch is app-wide, and the two shapes follow those two families rather than the shared word.

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.

An empty active Space refuses by name (#1336)

Section titled “An empty active Space refuses by name (#1336)”

[Trade-off]

A focused-window verb issued while the active Space is empty refuses, and the refusal names the Space that is empty, the window get_state marks focused in a sibling Space on the same screen, and the focus_space that brings it back under the verb — or the Space alone, when no sibling there marks a focus — never “no managed window is currently focused”, which names a fact that is not the case.

The measurement: move_to_desktop 2 leaves the window’s Space membership behind, and focus_desktop 2 opens that Desktop on its own remembered Space, so on arrival KiwiDesk shows Space 2 (empty) while the window sits in Space 1 — parked, and marked focused there. The #292 preflight reads the ACTIVE Space’s anchor, finds none, and refused with the generic sentence, which asserts a focus divergence that does not exist. The verb is not what decides the arrival Space: a swipe and focus_desktop end in the one switch handler, which activates the Desktop’s remembered Space (#1230) either way, so whether the user lands on an empty Space is a fact about that memory — Desktop 2 remembered Space 2 on 2026-09-08 and Space 1 on 2026-09-22 — and never about which of the two brought them there.

A refusal rather than acting on the parked window, because the verbs act on the Space the user is on. A window in another Space of the same screen is parked off its edge — the windows in front of the user are the ones to act on, and reaching for a parked one would point every focused-window verb away from them whenever the active Space happens to be empty. The honest answer is to say which Space is empty and where the marked window is; focus_space on that Space is a one-word recovery and the sentence names it. The cost, accepted: the user pays one focus_space where a swipe would have paid nothing.

One sentence on both channels. The log’s clause and the response’s error come from one reading (denialSentences), so a trace reads as the CLI does. The sibling Space named is on the SAME screen — a Space on another screen lays its windows out there — and where several mark a focus, the one holding lastFocused, the window that last held the system focus, else the first in Space order. An active Space with members and no focus slot keeps the generic sentence: the members in front of the user are still the ones to act on, so the refusal points at nothing beyond them.

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 keeps the frame its previous space drew, physically on the old screen while the Space Bar already lists 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.

KiwiVision’s blur is Liquid Glass clear, behind one switch (#2103)

Section titled “KiwiVision’s blur is Liquid Glass clear, behind one switch (#2103)”

[Rationale]

The blur is one on/off switch that draws Liquid Glass clear, pinned to its dark variant since the dim over it is always black — a plate has no Fill, so the pin outranks the Settings Appearance pick as a dark Fill’s does on the bars — and hud (NSVisualEffectView) only where glass cannot draw — macOS 14 and 15. There is no style picker, and no strength.

The owner compared Off, hud, glass regular and glass clear live, over a Scrolling and a BSP Space, with the dim beside each: clear reads clearly better; regular reads like hud at about half the dim, so it adds nothing. A picker would hand the user an engineering trade-off between two looks they did not ask to choose between, under a word (“hud”) no user knows — and “off” is already the switch. A strength control has nothing to drive: fading the blur shifts its colour rather than weakening it, and the backdrop’s private blur radius changes in the layer without any visible effect, since WindowServer draws a behind-window blur.

The blur is on by default, at a 60 % dim, and carries no battery warning. KiwiVision itself is off until the user turns it on, so the blur only ever costs anything inside a mode the user chose; over a still desk it costs next to nothing, and content moving behind it costs clear about twice hud’s GPU power — small next to the desk’s own load (measured in #2103). A warning on the row would put people off a feature for a cost no other drawn surface owns up to. Should hud be wanted on macOS 26 after all, it comes as a separate Lua-only style verb, never by turning the stored boolean into an enum, which would owe a migration.

Only the window focus leaves eases into KiwiVision (#2417)

Section titled “Only the window focus leaves eases into KiwiVision (#2417)”

[Rationale]

When focus moves with KiwiVision on, the window you leave fades into the dim and the blur, and the window you focus clears at once. The arrival is instant for two reasons, and tuning cannot remove either. First, macOS fronts the new window before KiwiDesk hears the focus change. The window is already above the plate and clear when the report arrives, so a fade can only dim it again and clear it a second time. On the device that read as the window flashing bright and then dimming. Second, dimming that window at all would need the plate above it. Its focus ring would sit under the plate too, so the ring dims and pops back, and every focus change pays two more WindowServer orders, which took up to 390 ms each under GPU load (#1925). With the leave fade alone, the plate stays where it already was, below the new focus. The fade adds no order and no second panel, only a mask region whose opacity runs from clear to covered. That mask must reach WindowServer before the order that puts the window under the plate, or the window is covered whole for a frame.

The blur fades with the dim, through the same mask opacity. The recorded colour shift of a partly faded Liquid Glass still holds, and it is why the blur has no strength control. Over a fade of at most a second it reads as the blur arriving, and switching the blur on at the start or once the dim lands was the visible step the owner compared it against. Because the window you focus never waits, a long fade on the window you leave costs no responsiveness. That is why the band reaches 1000 ms, where a fade on both windows had been capped near 300 ms; the default of 300 ms is the owner’s pick on the desk between 150, which read as hard to see, and 600. The curve is the user’s choice from ease out, ease in and out, ease in and linear. At this length they differ mostly in the first 200 ms, and the owner ruled that a matter of taste. macOS’s mild default curve stays out because it is ease out under a confusing name. Reduce Motion, a Space switch (#2228) and a lens hold change the dim at once.

KiwiVision switches the appearance and owes it back (#2103)

Section titled “KiwiVision switches the appearance and owes it back (#2103)”

[Rationale]

Appearance while on is Unchanged by default, Opposite, Dark or Light: turning KiwiVision on switches the system only where it shows the other appearance, and turning it off switches back only a switch KiwiVision made — a change the user made by hand while it was on is theirs, and stays. Without that rule the mode would fight the user’s own toggle on the way out.

The switch is SkyLight’s legacy theme call, measured to take no permission (P5 in the issue); System Events, which asks for Automation once, is the fallback where the symbol is gone. The undo is a debt stored BEFORE the switch and paid on every way out — the toggle, a quit, the next start after a crash — because a Mac left dark by an app that died is the failure a user cannot trace back to us. The debt is this Mac’s state, so it lives in the app’s defaults and never travels in a backup. A manual switch turns macOS’s Automatic mode off, so the payment turns it back on where it was on; that re-arm writes the preference and posts the theme notification, the one step no device has measured yet.

A lens steers the keys, never a pick (#2103)

Section titled “A lens steers the keys, never a pick (#2103)”

[Rationale]

While a lens keeps focus on its apps, focus, swap and the scroll gesture skip the windows the lens leaves out, and nothing else does: a click, a bar item and hover focus reach any window, and the window switcher lists every window, the lens’s apps first.

The line runs between a step and a pick. A directional key steps blind: it lands on whichever window lies that way, so inside a lens an unsteered press spends itself on a window the user was not working in, and the next press has to come back. A click, a bar item and a switcher row each name their window; refusing one would turn the lens into a lock, with a window the user can see and point at that will not take the focus. macOS’s own ways in — ⌘-Tab, the Dock, a notification — are never refused either, so a KiwiDesk refusal would hold back nothing they do not already reach. For the same reason the switcher orders and never filters: leaving other apps out of it would make their windows unfindable from KiwiDesk.

Hover focus is a pick by the hand resting on a window. The scroll gesture is a step: like a key, it moves the focus to whichever window comes next along the row, without naming one, so it skips as the keys do. One switch, Keep focus on these apps, governs both, since a lens that steered one way of stepping and not the other would still let the focus wander off it.

What a step skips is what the lens leaves out, judged per window as the plates judge a member: a member app whose title rule clears only some of its windows has the rest skipped like a stranger’s, since a step that lands under the haze is the press the lens exists to save. A Never dimmed app’s windows stay clear on screen but are no member, so a step skips them too: they are kept clear to be seen, not worked in.

A lens holds a stray landing, never refuses it (#2103)

Section titled “A lens holds a stray landing, never refuses it (#2103)”

[Rationale]

macOS’s own ways in — ⌘-Tab, the Dock, a notification — are never refused, so a lens that keeps focus on its apps still lets another app’s window take the focus. What it does instead is hold that window under the haze for the lens’s delay, a reminder pill centred on it, and then clear it like any focused window. The delay is the point of the lens made visible: the user sees where they landed and that it is outside what they meant to work in, and a deliberate visit costs only the wait. Refusing would make the lens a lock; clearing at once would make it no more than a dim.

Every landing waits, whichever door chose the window — a key, the Dock, a bar item, the window switcher, a Space switch — because the question is where the user is working next, not how they got there. The one landing that never waits is a click on the window itself: pointing at a window is the plainest statement that it is wanted, and making it wait would answer that statement with a delay. Typing does not end the wait, since a keystroke into a window is not a choice of it. A window that was never the user’s choice of a place to work never waits either: Never dimmed apps, KiwiDesk’s own windows, launcher palettes and other overlays, and the dialog of the window that was clear.

The plate orders above the held window rather than masking a hole shut, because the window must stay where macOS put it in the stack, and an activation lifts it back over the plate every time (the v3 probe), so the hold re-orders on each of its reorder events until the delay ends.

KiwiVision has its own page, and its rows apply at once (#2103)

Section titled “KiwiVision has its own page, and its rows apply at once (#2103)”

[Rationale]

KiwiVision is a mode the user turns on and looks at, so its settings get their own page, first in Whole App on Home, rather than a card on a per-profile page. Its Home card says whether the mode is on right now and how to turn it on, so it is worth reading on every visit; a picture row there is not drawn yet. Dim, Blur and Appearance while on are app-wide and write gui.json the moment they change, like General’s rows (Spaces, profiles & config ownership, #1741). With KiwiVision on, Settings stays clear while every window behind it dims as the Dim value steps, which is the best preview there is. Each card says “Applies at once, in every profile.”, save Never dimmed (below).

Stored as one group, with no crossing. The settings live in gui.json as kiwivision.{dim, blur, appearance}, every field optional, so a file written before them reads the defaults. KiwiVision never had a per-profile home, so nothing is captured or owed. A group rather than three flat keys let the lenses join beside these fields without moving a stored value. Every change of the live values, whether from a verb, a row, a reload of gui.json, a restore or a reset, reaches the plate and the appearance switch through one hook, so a reload while the mode is on never leaves the system dark under an “Unchanged” setting.

Dim is a stepper, not a slider. By docs/ui-patterns.md’s numeric-control test a proportion tuned by eye takes a slider, and that page lists Dim as the exception (owner ruling). A slider earns its place by showing the effect while it moves, and here the desk already does that better: with the mode on, every window behind Settings dims as the value steps. What a slider cannot give is an exact value typed and read back, so the stepper keeps the one thing the slider would have added nothing to.

The picture is drawn, in the side panel. A realistic desk under the plate’s real glass read too hard beside the rest of Settings (owner, on device), so the picture is drawn in the schematics’ language: the user’s palette, gaps and focus ring, four kinds of window. It invents no verdict. The veil takes the plate’s own dim mapping, the softening shows only where the plate’s blur door draws (so Reduce transparency drops it here too), and the windows take the appearance the mode will show, never Settings’ own Appearance pick. It sits in the side panel although the Default page has no draft (a lens’s page has one, and the panel draws it), on the carve-out argued in Navigation & saving ▸ the panel’s object is the DRAFT.

Lenses save with Save. A lens cannot apply at once: its shortcut must not register before Save, the profiles it applies to are written to other profiles’ files, and a rename rewrites stored bindings. The plain settings stay immediate beside them. That is why “Applies at once, in every profile.” sits on each immediate card rather than on the page, and why Never dimmed — the one immediate card a lens’s page shows — sits last, under “Every lens”, saying “in every profile and lens”: two save behaviours on one page each need their own sentence. A lens overrides the plain settings one row at a time, since a lens that changes the dim usually keeps the blur.

A lens’s reach is the profile’s mark, and leaving it turns KiwiVision off (#2103)

Section titled “A lens’s reach is the profile’s mark, and leaving it turns KiwiVision off (#2103)”

[Rationale]

Reach is stored in the profile, not on the lens. A lens lives once, app-wide in gui.json; a profile that does not take it says so in its own file, as a sparse lens_reach mark. It is the storage App Rules’ Applies to already reads (#1393), with absence meaning “reached”: a lens defined later reaches every existing profile, a profile created later takes every lens, and a built-in Standard, which has no file, is reached by all of them without anything stored for it. A profile list on the lens record would invert each of those. The mark rides the live profile’s adoption state and follows every write of its file, so the switch path decides from memory and never reads a profile file (#1245).

A switch out of reach turns KiwiVision off, not down to plain. Falling back to Default would leave the user in a mode whose way out is a chord they never pressed: a lens’s chord turns KiwiVision off only while that lens is on. Off is the state every user already knows how to leave, and it pays the appearance switch back on the one off path. The pill names the lens and the profile because the user did not ask for the change. A switch back does not turn the lens on again, since a switch is not a press. Plain KiwiVision is app-wide and has no reach, so it stays on across every switch.

“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.)

Wide controls share the label axis; compact ones trail (#2439)

Section titled “Wide controls share the label axis; compact ones trail (#2439)”

[Principle]

A slider or segmented strip starts on the shared label column and fills the row; a menu, a stepper and an action row’s buttons trail at their natural width against the row’s right edge; an on/off is a checkbox, never a switch, and a setting’s own checkbox leads with its label beside it. A wide control needs the room the column leaves it, and starting every wide control on one line is what makes a section comparable at a glance. A compact control on that same column leaves a long empty tail after it. A stepper trails by its own anatomy, so a menu left on the axis would put two compact kinds on two sides; moving menus to the stepper’s side makes the split one rule: wide on the axis, compact at the edge. Every override row’s inheriting readout trails, whatever its live control: the unchecked list is read as a table of inherited values beside the OVERRIDE column, and a readout that trailed only for a menu or stepper left that column ragged on any Space mixing row kinds (owner, on device, 2026-10-19). Ticking a menu or stepper row moves nothing; ticking a slider row moves the value onto the axis, and a toggle row’s value becomes its checkbox. The slot-size pair has no one-line readout and keeps its dimmed live control instead (#290).

Two exceptions are ruled. The Windows on screen stepper sits on its slider’s control axis, since the count is the slider’s own second reading (#1382). The override chrome’s OVERRIDE column (overrideStateColumn) is the one trailing checkbox: it answers whether this Space deviates, not the setting’s own on/off.

Two alternatives were weighed. Moving everything trailing, as System Settings does, changed every row type, gave segmented controls and sliders a width rule of their own, and turned every toggle into a switch. Moving the stepper onto the axis instead kept one column but left menus and steppers with the long tail. Checkboxes stay leading rather than becoming trailing switches: in a per-Space override row a switch would sit right beside the OVERRIDE checkbox, two toggles meaning different things in one row, and switch-alone-but-checkbox-in-a-list is a second on/off control to learn. The stated cost: a card that mixes row kinds puts controls on both sides — checkboxes left, menus and steppers right, sliders and strips between — which is not System Settings’ look (Apple-native binds behavior says it need not be). Below the row breakpoint (SettingsWidthClass.rowBreakpoint) a trailing row stacks like every other, and at the content cap (SettingsTheme.contentMaxWidth) a menu sits as far from its label as a stepper does. Owner ruling 2026-10-19, SettingsRowShapeTests ▸ compactControlsTrail, ToggleStyleConventionTests.

An action lives on the card whose values it writes

Section titled “An action lives on the card whose values it writes”

[Principle]

Fit layout gaps writes the GAP values, so it sits last in the Gaps card (#1360) — not in the Focus border card that holds the width it reads. A user looking for the thing that changes their gaps opens Gaps; finding it under Focus border reads as a border setting, and showing it in both is two controls for one action. Reading a switch on another card is what greys it: the rows dim with the focus border off, resolved through the area’s gate resolver like every census gate, and the reason is drawn inline beneath the dim — the cause is on the same page but a different card, which is the one channel GateReasonPlacement routes to words rather than a ? or a pointer (#815). The general rule is the placement ladder in docs/ui-patterns.md: an escape hatch is last on the card whose values it transforms.

A repeating slot is offered as a count; a split as a share

Section titled “A repeating slot is offered as a count; a split as a share”

[Principle]

The Scrolling slot’s Percent row carries a Windows on screen stepper beneath the slider (#1382), because on a wide screen the value a user holds is the effect, not the share: three windows, not 33.33%. The noun is the user’s — a column is tiler vocabulary, and the label does not switch with the orientation (owner ruling 2026-09-16, adopting ui-designer). What the count promises is SIZE: n windows fit. What the eye sees is the anchor’s — under Center a centred row meets both screen edges, so an even count shows the outer two as halves — and that belongs where the anchor is known: the schematic’s caption under Center carries the cut-windows clause exactly where the frame draws a window the edge cuts (LayoutSchematicCenterCaptionTests), never the row’s ?, which is the same sentence for every anchor and would tell a Left, Right or Follow user about Center. ”—” gets no sighted-user gloss either: it is the native indeterminate glyph beside a live readout, and ▲/▼ visibly land on a whole count. It is a stepper by the numeric rule (a count is a number), typeable like every stepper — an integer only, fraction parsing refused — and it writes 1/n into the one stored fraction Lua and profiles already hold; the slider stays for feel. The field shows ”—” (spoken “not a whole count”) whenever the stored share is not 1/n at the wire’s own precision — percentString equality, never a hand-typed tolerance. ▲ from ”—” lands on ceil(1/f) and ▼ on floor(1/f), never “nearest”, which reverses direction at the shipped 95%. ▲ greys where one more window would fall under the minimum window size on the widest connected screen (the space’s own screen in the override editor) — a cap Core computes from the terms ScrollingLayout.metrics draws with, through KiwiCore.scrollingColumnCap, which picks the screen and reads its size through the one bounds hook, never GUI arithmetic over a screen frame — and never past the share’s own 5% floor, which is the whole edge where no screen is known. That is the first live-machine read in Layout Defaults, and a bound that greys differently on different Macs for one profile — the stated cost. The count writes the share the wire decodes, so a stored third re-read from disk is not a change, and one percent formatter — the wire’s own spelling — is shared by the readout, the spoken value, RatioRow, the inherited-value pill and the diff pill, so nothing on the card shows a number the count row or a chip would refuse; a schematic caption still rounds to a whole, being a label on a picture rather than a value to copy. The row is its own census key drawing no diff row: the leaf narrates once through the value row. The split rows — the stack master ratio, the bsp ratios — keep fraction chips ¼ ⅓ ½ ⅔ ¾ instead: a share is not a count. A text field accepting 1/3 was rejected: a closed set is a chooser, not a parser, and anything outside it is Lua’s.

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: binding the spaces list to a conditionally mode-gated button, which on a fresh install is not drawn at all, sends focus 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 (the Colors & glass colour rows are 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.

Channel 3 has two shapes (#1310, owner ruling 2026-09-14 on the device), 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 Space Bar colours card with a ? on its heading was not understandable on the device. 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 (ColorsAndGlassHelp.focusedItemReference). The Borders card is the same shape — Unfocused windows greyed alone while the ring is on — and takes the same link (unfocusedReference); its header ? answers only for the ring being off, when every row is grey. The header ? stays the shape for a greyed block.

A whole card an OS accessibility switch greys also says so in a warning box (owner ruling, 2026-10-21). The Animations card under Reduce Motion keeps its header ?, and adds the yellow box the Modifier keys card uses, at the top of the card and only while the switch is on, with the one fix — a button to System Settings ▸ Accessibility. A ? is found by a reader looking for why; a card whose every row is grey by a setting KiwiDesk cannot change was read as broken before the ? was found, so the box states the cause where the eye lands first. It is for that class alone — a cause outside the app greying a whole card, which the Liquid Glass card under Reduce transparency owes too — and a row greyed by something on the page keeps the channels above (GateReasonPlacementTests ▸ osSwitchWarnsInABox).

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. 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. Residue: 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: a change here is verified on a device with VoiceOver on, since 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 are each rejected on grounds that still hold: a visible ⋯ per row is clutter (owner rulings 2026-08-04 and 2026-08-11), a whole-chip Menu eats the .draggable it sits on (measured on the assignment chip, where it 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 sends the chord to the first row on the page whatever is focused, cross-targeting destructive items (#845) — 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 existed when this was ruled: 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 is built from the first alone says Unsaved changes over nothing, and a header deriving the same fact on its own says 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, not the header’s: borrowed, those 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.

A live change nobody saved is a row of its own (#2262)

Section titled “A live change nobody saved is a row of its own (#2262)”

[Rationale]

Settings seeds its draft from live, so without its own row a stored setting an AI app, the CLI or a Lua call changed live reads as saved: no pill, no row, and the next Save writes it into the profile without saying so. The change is a further reason the pill appears, and by #1197’s ruling it is a row — under its own Live, not saved heading, narrated saved → live, so it never reads as an edit made here; the panel’s “Changed in this draft” list leaves it to the pill for the same reason.

Core decides which leaves count, through the one KeepJudge the change report and keep_settings read (#1685) — keepable and not stored — and Settings only diffs and narrates the two sides it hands over. A second comparison in the GUI would list what no keep could write: an app-wide value, a temporary Space’s leaf, a Lua-owned config.

Save writes the rows into the profile with the draft, because a Settings Save lays only the draft’s own edits over live as it is at the Save, never the settings it was seeded with — a whole-settings apply would put back every live change since, these rows among them. A row also keeps alone, without the draft’s edits (#2368): each row’s Keep, and Keep All once the section lists more than one, write exactly the leaves that row narrates through the same keep_settings judgement, so keeping one layout’s value never keeps its sibling’s, and a leaf the keep cannot write stays listed with the reason under it. A per-layout App Bar indicator on a profile wearing the shared look keeps like any other leaf (▸ Profiles share one look). Closing Settings reverts nothing live and never asks the leave question: these are not the window’s edits. A setting the draft also edits is the draft’s row alone, since that edit is what its Save commits. Undoing a live row means applying the saved value live, a Core verb rather than a draft writeback (#2411, ▸ A row’s revert writes its one key back through a register).

A row’s revert writes its one key back through a register (#792)

Section titled “A row’s revert writes its one key back through a register (#792)”

[Rationale]

A ↺ on a diff row takes that row’s change back to the baseline and nothing else. The diff names a change by its census key, read off Swift property paths, so the way back is a register from each key to a writer — SettingsRevertRegister — and not a Codable splice of the baseline’s encoded subtree. Measured on main 2026-10-18, a splice reached 59 of the 209 keys that name a model path: 139 settings keys name their leaf differently on the wire, and 11 profile-scoped config fields are not encoded by GuiConfig at all, so a splice would have needed a hand map for most keys anyway. The census id is the Swift path, so the register is generated from the ids and every writer is held to its key (SettingsRevertRegisterTests).

A writer reverts at the row’s instance — one Space’s override field, one app’s rule, one rule-list entry, one Main Space — and whole where the row names none. Where state is written together it goes back together, so a ↺ can take more than its own row, and these are the groups: a Space in the list goes back through the Space doors, taking what is filed under it; a bar’s edge goes back with its per-screen map, as the edge doors write them, and the master with both bars’; a rule takes the reach the draft gave it (#1393); and a shortcut binding goes back with every changed binding of its layer that its saved chord, or the chord it now holds, was moved to or from (#2412, owner 2026-10-20) — the recorder’s steal writes both in one gesture, so the ↺ on either row undoes the move and both rows leave. The set is read from who holds each chord at revert time, never from a stored history, so a later edit still counts; it never crosses a layer or takes an unchanged binding, and one pure function (BindingRevertSet) gives the ↺ both its set and its words (“Revert, with Swap Left”). The binding’s reach picks go with it. The alternative, taking the chord back alone, leaves the binding it was moved to unbound — a state nobody saved — and still needs a second press. A ↺ that takes more than its own words name asks first (a binding’s names every binding it takes): a layer’s or a lens’s create, delete or rename puts the whole layer or lens back with its bindings, and a “Layers edited” row, which names nothing, takes every shortcut change (RevertWriter.grouped). A row for a layer’s switch or a lens’s toggle goes back with that layer or lens, since alone it would bring back a row for a name the page no longer has, and a layer or lens whose saved name another one now holds draws no ↺: it cannot come back under that name, and taking the newer one’s name away is not a revert. The reach rows are grouped the same way: a profile that changed exactly as the edited one did is named on the edited rows (“All profiles” for a shared rule, else a pair or a count) instead of in a row of its own, and only profiles that changed otherwise keep rows, one per change. Eight rows saying one thing read as eight changes, and the pill counted them so. The same holds for the settings one control writes together (#2450, SettingKey.summaryAxes): the Outer gap’s four edges, the Inner gap’s two and the minimum window size’s width and height per layout are one row named for the control while every axis holds one value — when the master’s own readout can name one value — and a row per changed member once they differ, so one nudge of the slider is one change. The fold happens where rows are narrated and never in the diff, whose per-edge keys the live rows and the sync adopt compare across different diffs; the folded row’s ↺ takes the group back without asking, since the row names it. Their way back is the rule’s: the reach settles once the app’s rules, or the shortcut layers, are the baseline’s again. A row says how it reverts and offers no ↺ until it does: profile drift and a leaf no key claims draw none.

A live row’s ↺ is not a draft writeback (#2411): it puts the row’s leaves back to the value the live profile or its shared look stores and applies that live, through revert_settings, the twin of keep_settings — so a row can be kept or reverted, and nothing else of it changes. The pill’s Revert takes every live row back with the draft, so the list it was asked to clear empties. Drift is the one row it leaves (owner ruling 2026-10-20): drift is about the arrangement, and re-applying the saved profile to clear it would end a standing temporary layout, which Revert never does (#1179).

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.

The other way round — a field-shaped button opening a popover that holds the actual field and the results — is defensible while the button is a small pill, since a popover takes the key window for free and focus and dismissal come from AppKit, and indefensible once the field grows 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 produces a result, which is why the popover shape held the field inside it. 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: the zIndex goes 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.)

The search results are as wide as the field, and on glass (#2399)

Section titled “The search results are as wide as the field, and on glass (#2399)”

[Trade-off]

The results follow the search field’s width, clamped to 380–560 pt and never past the header’s trailing edge. A fixed 380 pt panel read as detached from a wide field. Full field width is not on the table: a row’s value would drift a thousand points from its title, and the suggestion lists in Safari, Spotlight and Xcode’s Open Quickly are bounded too.

The card is the one surface on Settings’ own content plane that takes Liquid Glass. The pill and the KiwiVision picture read badly on glass, because they sit inside the page and carry their meaning in fills and hues the glass washes. The results card floats over the page, appears only while a query is typed, and is a list of words. The glass is a layer under the rows, not around them: content drawn inside the glass is drawn vibrant, which washed the destination tiles out on the first device look. Under Reduce transparency the card falls back to its opaque fill and takes its shadow back. The owner kept both on the device, 2026-10-18.

The icon picker’s popover is the second surface, by the same test: it floats over the page and is only up while the user is choosing. Its symbols are ink, and its emoji are pictures in their own colour that are drawn on top of the glass, not inside it. The chosen icon’s accent wash sits on the cell’s own fill, so it reads against that fill rather than against the glass. Its fallback stays a popover material, since that was its design before the glass. The owner kept it on the device, 2026-10-19. A further popover takes glass only by passing the same test, ruled here.

A Settings popover is words over the page, up only while it is read or chosen from, so it takes the same surface (#2503); a name editor’s field draws its own bezel on the glass. One whose meaning rides in fills stays on the material, as the pill does — SettingsPopoverChromeCensusTests names each and why.

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 — 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 are read from the model (#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 the shelf and search share, 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.

Ranking is one stable destination order. An exact match the user asked for never sinks for the page it lives on, and a list that reorders on some other state reads as random.

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 two per-layout App Bar show toggles predate the ruling and stay as shipped.)
  • The colour rows are no exception. They once took none on the ground that colour is a browse — nobody types “group badge text colour”, they open the page and look — but that held only while they had a page of their own at rest. Since #2389 they sit in Looks & Animations’ Colors & glass section, since #2593 every colour but the Fill in its shut Colors drawer, so a hit with no anchor lands on a page that does not show them, the one failure this split exists to prevent. The browse argument survives inside the drawer: open, it hides nothing, and the anchor only opens it. The two role groups’ wells both draw “Ticked rows”, which the label-key join cannot tell apart, so their census label is their group’s title instead: the hit lands on the group, the drawn words reach them as a search synonym — the one place the census label is not the drawn one, ruled rather than left anchor-less, since anchor-less inside a shut drawer is the failure itself.

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, #1470, 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 drops over Home every time Settings appears (#1468). One row there, the Guide (#1030), on the argument that an .accessory app has no Help menu and the search field is the closest thing to one, is refused: that argument belongs to the Mac Checklist’s foot, the app’s one permanent guide pointer (▸ The app links the guide…). results("") returns nothing, so a bare Return in an untouched field cannot navigate somewhere the user never named (SettingsSearchTests ▸ emptyQuery), and any list volunteered under the field would be a second navigator, which “Home is the only navigator” rules against. The narrower shape — keep the row, show it only on a focus the USER gave — is 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 behaviours ride along: 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.

A bordered action button lifts grey under the pointer (#2377)

Section titled “A bordered action button lifts grey under the pointer (#2377)”

[Principle]

Every bordered action button — in Settings and in KiwiHub — draws on the button-chip tier and lifts one grey step under the pointer, one more while pressed (owner ruling 2026-10-17). A system bordered button does not hover on macOS, and Apple-native binds behaviour, which argued for leaving it alone. But a bordered text button is a box with a word in it: beside labels and chips that do lift, it reads as a tag and is overlooked, and KiwiHub’s footer buttons were the one thing in the panel that did not answer the pointer. One small grey lift, on the same ladder every chip already uses, keeps every button behaving the same and stays close to the platform. The lift goes only where a shape can be mistaken for text: an accent-filled primary is the loudest thing on its surface, and a menu picker’s chevron, a stepper’s arrows and a checkbox’s box already say “control”, so they keep system behaviour (docs/ui-patterns.md ▸ Hover confirms custom hit areas). The chip keeps the system bezel’s rounded rectangle: the lift is the change, not the shape. A segmented picker’s unselected segment lifts one step over its track and presses one step further, the same order. A palette or look tile lifts too. A lift promises the click: what a click would leave alone — the applied tile, a KiwiHub item the query dims — stays flat (owner ruling 2026-10-17).

Hover help appears sooner than AppKit’s default

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

[Trade-off]

KiwiDesk stores a shorter NSInitialToolTipDelay (250 ms) in its own defaults domain, against AppKit’s default of roughly two seconds. A user who has chosen their own delay keeps it: launch writes the value only where none is stored, or where the stored one matches the marker (KiwiDeskToolTipDelayWritten) recording what KiwiDesk itself last wrote — so a retune reaches our earlier value and never a user’s.

It is stored because registering it does nothing. AppKit’s tooltip code reads the stored preference and never the registration domain (measured 2026-10-01, macOS 27): a registered delay left the two-second default in force, while the same key written with defaults write took effect at once. A registered fallback would have been the more polite shape; it is not an available one.

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: too short, and tooltips fire while the pointer merely crosses a row on its way elsewhere, and Settings becomes a field of popping yellow. Where that floor lies was settled on the device, not by estimate (owner ruling): at a quarter second help appears once the pointer rests, and a pointer sweeping across rows still does not set it off. Settings’ help is read, not stumbled on — a reader who rests on a greyed control has already decided to ask — so any margin above the delay that still needs a rest only makes them wait for an answer they came for.

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 is the failure. Under promote-on-open / demote-on-close, a demote has to survive being the last of {Settings, onboarding, Config Issues} to close — one rule spread over three controllers, each holding half of it — and removing the demote from one while leaving the promotion in another leaves a reachable order (onboarding → Settings → close both) that strands the app .regular with nothing on screen, 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.

The same routing is why Close lives there: ⌘W reaches a window only as a key equivalent of a menu item, so without a File ▸ Close item nothing answers the chord in any own window (#1533). The item carries no target and the key window answers it — and a key window built without .closable, which AppKit greys Close for, validates and performs the item itself so the chord means the same thing at the Shortcuts panel as at Settings.

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, so the prompt arrives behind everything the user has open with nothing saying the update is 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.

Unsaved edits ask at the window; a quit closes every dialog (#2049)

Section titled “Unsaved edits ask at the window; a quit closes every dialog (#2049)”

[Principle]

The Settings draft lives exactly as long as its window. Closing the window with unsaved edits — the close button, ⌘W, File ▸ Close — asks Save / Discard / Cancel, and a quit while Settings is open with unsaved edits asks the same with Settings brought to the front: the user’s Quit, Install and Relaunch, a quit another app sends. With Settings closed there is no draft, so no quit ever asks. This amends #455’s “the draft survives a close”: a draft that outlived its window could only be met again as a question from an app showing nothing, or be dropped silently by the next quit, which is what every quit did before. A document app asks at its window and at a quit while the window is open; Settings now does the same, and a visible window is never a hidden prompt.

The question cancels the quit; its answer quits again. A quit that must ask returns .terminateCancel, shows the question, and Save landed or Discard calls terminate(_:) once more, passed through once. It never holds the quit with .terminateLater: that runs the run loop in the modal-panel mode, where other apps’ accessibility notifications are not delivered (they are observed in the default mode alone, accessibility.md), so tiling would go deaf for as long as the question waits. A logout, restart or shut down is never asked about: macOS asks a menu-bar app to quit only past its point of no return and logs out whatever it answers (measured on macOS 27.0, 2026-10-10, #2135), so the power-off notice and the quit event’s own reason discard the edits as a SIGTERM does; saving them unasked was ruled out, as a write of changes never confirmed. A second quit while the question is up brings it forward rather than slipping past it. An in-place restart intent is withdrawn while the question waits and re-armed with the same source on the answer, so a Cancel leaves none armed and a late answer still restarts in place. A Cancel followed by a second Install and Relaunch and then an answer does not restart in place: Sparkle tells the app it is about to relaunch once per install, so the second quit arrives unannounced, gathers the windows, and Sparkle still relaunches. A quit event from another app or a logout that arrives while the question’s naming prompt is open finds an alert AppKit closes on its own: the quit is aborted, the draft is kept, and Settings is in front with no question showing.

Every other Settings dialog closes as Cancel on any quit. KiwiDesk runs in the background, so a dialog forgotten in an open window must not hold up a restart (“KiwiDesk interrupted restart”). A dialog’s Cancel loses nothing, and the unsaved edits it sat over are asked about by the question above. AppKit closes only an NSAlert sheet on its own and refuses termination for any other sheet, and refuses a logout’s quit event before terminate(_:) runs, which is why KiwiApplication closes them ahead of terminate(_:) and the power-off notification closes them ahead of the quit event. The logout half relies on macOS posting that notification before the quit event; the evidence is one restart’s log (2026-10-09, #2120), where autosave frozen: logout or power-off began precedes the stop.

A SIGTERM discards. A kill, launchd or kiwidesk service restart cannot wait for an answer, so it quits without asking and the draft goes.

The question’s verbs. Save is the default, on Return; Discard is destructive, on ⌘D; Cancel is on Escape and keeps the window open or cancels the quit. Save is the footer’s own Save: where it needs a name the naming prompt opens and the close or quit continues only once that save lands, and a save that fails keeps the draft and cancels. Where saving is blocked (#335) the question offers Discard and Cancel, Cancel the default, with the existing reason. It is a kind of the one discard gate (PendingDiscard), so it shares that gate’s single pending slot.

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 is already happening; the tour narrates it. The moment the user presses Start Tiling (#2050), management starts and every window behind the tour is arranged — over the following seconds (#801), the screen narrating 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 — so the tour points at it rather than announcing “Permission granted!”, 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.) Force-floating every own window by isOwnProcess(pid) is 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 sweep 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, each 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 (KiwiDesk.open_settings(), offered under Shortcuts & Gestures ▸ Open applications ▸ KiwiDesk) — a window that lives among the user’s tiled windows earns a keyboard road back. Which chord it ships on is the seeded ladder’s ruling (Shortcuts, #1381). 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 kiwihub.show 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: 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.

Granting Accessibility is not a request to tile (#2050)

Section titled “Granting Accessibility is not a request to tile (#2050)”

[Principle]

The permission and the start are two decisions, and only the second moves a window. The grant step asks for what macOS needs; nothing is arranged until the user presses Start Tiling, which the grant page offers greyed beside Open System Settings and then as its default once the grant lands. A user who met KiwiDesk rearranging every open window the moment they flipped the switch read it as a bug and uninstalled, and nothing on screen offered a way back. Zero configuration is kept — the press asks for no choice — so “works at once” survives while “acts without asking” goes.

The press is remembered in three states, not two. A first run records “not started” before the grant page appears; an install that predates the gate has no record at all, and is counted as started when it launches already trusted. A two-state flag would read a first run that granted, closed the tour and relaunched as the second kind, and tile without asking on the next launch — the report itself, one launch later. Once pressed, a revoke and re-grant resumes on its own: that user already said go.

Closing the tour without pressing leaves KiwiDesk idle, and the start stays reachable without the tour: a Start Tiling row heads the quick menu, Settings carries a banner with the same button, and the menu-bar icon is drawn dimmed — the starting phase’s treatment, so the icon reads “not doing anything yet” from launch to the end of boot. The warning triangle was ruled out because its words say “permission required”, which would be false, and because it makes a choice look like a fault; the #1013 update dot was ruled out as the idle mark because it belongs to offers. Unlike the warning and the starting phase, idle does not outrank that dot: it can last indefinitely, and an update is something an idle app can still take, so the dot rides the dimmed icon.

An undo of the first arrangement is not owed by this ruling: once nothing moves unasked, the surprise it would undo is gone, and a restore is weighed on demand rather than as a safety net.

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 machine-gated step widens the variance again (the Displays recommendation was one until #888; the principle 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. Native glyphs alone — ⌃⌥ ← ↓ ↑ →, ⌃⌥ 1–5 — are a perfectly good reminder for someone who already knows the symbols, and 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, both of which look right until 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, which are deliberately not repeated here: a hand copy of a measurement drifts from the suite.

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”. A bare ⇧ — on the reasonable argument that it is on every keyboard ever made and needs no gloss — looks 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.

✦ takes Settings’ name for it, through its catalog key. On the Hyper shortcut base the chord’s first chip is ✦, and its word is shortcuts.hyper_key.name — “Hyper”, the name the Shortcuts page already gives that key. That makes it keyed rather than a fifth verbatim token, so the closed carve-out of four stays closed. It is a name, not an abbreviation, so it keeps its capital beside ctrl and opt, and it may overhang its chip: ✦ stands where ⌃⌥ stand on the other base, so a Hyper row is held to be no wider than the ⌃⌥ row (OnboardingModifierNameTests ▸ namesStayInsideTheirChip). The name of the key the user actually holds (Caps Lock, say) is rejected: it is not what Settings calls this key anywhere. VoiceOver reads the glyph as a star, so every spoken chord — the tour’s keycaps and Settings’ chord surfaces alike — names ✦ by the same word, through SidedChordWords.label.

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.

One page’s rows are a budget, and the last row is KiwiHub (#2515, owner rulings 2026-10-23). The fixed window holds nine one-line rows in every catalog (OnboardingBarsFitTests), so a row joins only by another leaving. The navigation tiers stay; then the single-key toggles a new user would never find — Maximize, KiwiVision, the bars’ show/hide as one row; then KiwiHub, naming its sides over their chords, which is the way to everything the page leaves out. Open Settings gave its row to KiwiVision: the menu-bar icon and KiwiHub both reach Settings, while nothing on screen hints that KiwiVision exists. A two-column grid of toggles was rejected: at half the width a German label wraps to three lines, so it costs height rather than saving it. A bar set to Always leaves the bars row, since its shortcut can only refuse — the one place this page reads a setting rather than a chord, through the shortcut’s own gate.

The tour’s look is written through, and the tour owns its undo

Section titled “The tour’s look is written through, and the tour owns its undo”

[Principle]

A click on the tour’s Looks step is the whole act: it paints KiwiDesk live and writes the same change into the live profile’s file, and the step carries its own Revert (#1720, owner rulings 2026-09-28). Everywhere else in Settings an edit is a draft until Save. The tour has no save pill and nothing that narrates a draft, and a user who picks Taskbar, watches their windows retile above the new bottom bar and clicks Continue has chosen: a draft there would ask them to commit a choice they already watched take effect, or keep it only until the next reload.

Writing through leaves Settings’ Revert nothing to undo, so the undo lives where the change was made — one path, which is why a “current colours” tile was ruled out as a second. The step’s Revert is greyed until a click changed something, and returns what a paint can reach to where it stood before the first one — the look and palette keys, and the glass leaves and per-layout indicators a look writes beyond them — and nothing else: a setting outside those saved from Settings meanwhile stays, while one inside them returns with the rest (ShelfPaintTests ▸ restoreTouchesOnlyTheLook, ShelfPaintRoundTripTests ▸ revertRoundTripsEveryLook). A pick that changes nothing and a step left untouched write nothing (OnboardingLooksTests ▸ noOpPickWritesNothing, OnboardingLooksTests ▸ untouchedWritesNothing).

Three obligations keep the write honest:

  • It writes what the click changed and nothing else. The file half reads the stored profile, paints it and writes it back, non-adopting, and never snapshots live the way Keep does (#1179; ShelfPaintTests ▸ fileKeepsItsOwnSettings). A Revert is refused once another profile went live, whose settings the baseline never described (ShelfPaintTests ▸ restoreSkipsAnotherProfile).
  • An unsaved live-profile Settings draft greys the step, since its Save would write the old look back over the new one — greyed and never hidden, with a caption naming the buttons that clear it (OnboardingLooksTests ▸ draftBlocks). A clean draft re-reads after every paint, through Core’s onLiveProfileWritten on the write (ShelfPaintTests ▸ paintsAreAnnounced, OnboardingLooksWiringTests ▸ paintFollowsIntoSettings).
  • Where no saved profile is live, the click lasts the session (ShelfPaintTests ▸ noProfileIsLiveOnly): there is no file to write. A first run always has one, the seeded Starter; only a replay over a built-in layout reaches this, and Settings already narrates that layout as unsaved.

The step has no “Keep previous colors” row: its Revert already returns the colours the first click replaced, so a second way back would be the second path ruled out above. A look click paints the look’s shape and its own colours, and the palette row below then repaints them and keeps the shape — the later pick wins (ShelfPaintTests ▸ paletteKeepsTheShape). The marks are read from the live settings on every render: after hand-tuned colours no palette reads selected until a click, while a look whose shape is still live keeps its mark and says “Other colors” (ShelfLookApplyTests ▸ matchReadsShapeThenColours).

The bars step (#2515) is the same act on the bars’ visibility: a pick writes through, its Revert returns the visibility leaves alone, and the two steps’ Reverts leave each other’s writes standing (TourBarVisibilityTests ▸ barsRevertKeepsTheLook, TourBarVisibilityTests ▸ looksRevertKeepsTheVisibility). While the Looks step is on screen it holds every hiding bar shown, over the windows and retiling nothing, since a look painted onto bars hidden at rest is a click with nothing to see (OnboardingBarsTests ▸ onlyLooksHolds, ShelfRevealHoldTests). A bar’s shortcut is refused under that hold, which would otherwise move only what the release gives back.

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 reads as a bar graph, and a bare face sits 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. The tour’s last button lands on this card, because 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]

Without a pointer in the app, a user who finishes the tour and later wants to make the setup theirs has to find the site on their own.

The tour’s closing card carries neither a reassurance nor an Open Settings footer (#1019). A quiet closing 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” is worse than the card without them.

The first clause is 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 sorts the reader before it reassures them: it makes 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. A card saying four things about where to go next has two of them arguing 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, leaving 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.

The default action lands on the Mac Checklist card (#1365); ▸ 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, would otherwise have no route to the guide anywhere in the app.

That permanent pointer is the Mac Checklist’s foot (#1365, #1470; owner ruling 2026-09-15) — not General ▸ About’s bare “Guide” link and not the tour’s closing card: the tour’s button lands ON the checklist, one line above the sentence, and two permanent pointers to one page is the drift a one-pointer entry exists to stop. The search declaration sits with it, so “guide” typed into the field 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 — on every display count, withheld only while a stored profile is edited (HomeCardOrderTests ▸ cardCounts, DestinationParityTests ▸ profileContextExcludesOnlyProfileless) — 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.

The AI page sets an AI app up and never reports on it

Section titled “The AI page sets an AI app up and never reports on it”

[Rationale]

KiwiDesk cannot see what an AI app has configured without reading another app’s files, so the AI page offers setup and claims no state: no “configured” line, and a fixed Home subtitle — the one card that is not an answer (gui.md ▸ Home), since a status built on a guess is wrong exactly when it is read. Nor is there a KiwiDesk switch: configuring the AI app is the activation, that app’s per-server switch is the off, and a KiwiDesk gate would stop nothing the command line cannot already do.

The setup prompt is English and has no catalog key: the AI app’s model reads it, and its command path and JSON must reach the model verbatim; a “Reply in .” line asks for the Settings language instead. The ready-made prompts are localized, since the user reads them, and each has the AI app interview the user one question at a time, so it works pasted unchanged. A ready-made prompt lands with the tools it needs and is never drawn before them — removed rather than dimmed, as with any entry whose channel does not exist yet. Copy Setup Prompt and Claude Desktop’s Add… grey on a translocated copy through the one RunningCopy verdict the login item reads too (▸ Open at login), because the path each one carries dies at the next launch. Claude Desktop’s Add… shows nothing after the click: Claude Desktop’s own install dialog is the feedback, and KiwiDesk cannot see whether the user accepted it. It reads only whether Claude Desktop is installed, which greys it with its reason.

The Claude Desktop extension is built on the user’s Mac at the click, with the running copy’s path and version in it, so it always starts the copy that made it — wherever that copy lives — and never a stale one. It is unsigned, since no key is on the user’s Mac and it carries nothing but a launcher for the notarized app, and no release attaches it, since anyone who can use it already has the button. The Claude Code plugin’s source lives in plugins/ beside the code it is tested against, while its marketplace is a repository of its own: Claude Code adds a marketplace by cloning the repository that holds it, and this one is about 100 MB even as a shallow clone (#2258 ▸ Ruling).

A look matched to the wallpaper is made from a screenshot the user hands the AI app; KiwiDesk never reads the wallpaper itself, since reading the desktop picture raised a macOS folder-access prompt (measured 2026-10-05, #1685 ▸ Ruling 6b). A wallpaper-colour verb reopens this only with a read that raises no prompt.

[Principle]

Settings owns the login item; crash supervision is the CLI’s. (#342, #576, #678 item 16, #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.

One switch, because the two mechanisms are two launchers. They are not one setting with two faces: one switch installing both makes them race for the instance lock at every login, and 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 — 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 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 never offered in Settings at all (#1071) — 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 is refused in the fold, not made unrepresentable in a control. 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. Folding both into ONE three-level picker makes the pair unrepresentable (#576), but the supervision half is advanced and does not belong beside the language pick, so the picker splits into two switches (#678 item 16) and re-opens the shape. The constraint therefore lives one layer down, where it is total: AutoStartLevel.level(openAtLogin: restartOnCrash:) discards the restart flag whenever login is off, so no caller can express the contradiction whatever its toggles say — the layer that holds for a CLI verb, a restored preference or a test, anything that never passes through the view.

Removing the second switch ends the pair (#1071). The GUI expresses no level at all: 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, which is why setAutoStart and AutoStartManager.set/apply go with the row — a write path for a pair nobody can express is dead weight that would invite the pair back.

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 — and the two subsystems are the authority. 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. 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 (a loaded service’s RunAtLoad also launches at login) is 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, a visual-effect backing) resolve their appearance when made and do not re-read it when the modifier returns to nil, so Dark → System strands them dark while Dark → Light — a new concrete value — looks fine. 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.

A second surface overrides the pick (#2569). Under Tint KiwiHub and plates, KiwiHub and the cue plates take their scheme from the Fill in both directions: dark for a dark Fill and light for a light one. They are SwiftUI glass, which can pin light. The bars can pin only dark because that is AppKit’s limit, not a ruling (▸ Liquid Glass, The light limit is AppKit’s).

[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. 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, since 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).

The count’s floor is per layout, and it reaches 1 only where one window draws two ways. The slider began at 2 because at one window every layout drew the same full-screen rectangle — a frame that teaches nothing — and several schematics have no second zone to partition below it. “If one window, fill the screen” (#1389) ends that for Scrolling and Stack: their lone window fills or keeps its slot or master zone, which is exactly the fact a preview exists to show, so their band reaches 1 and draws it, with the caption switching on the toggle. The other layouts keep the shared floor rather than growing a lone frame nobody can learn from, and a count the slider’s state carries across a layout switch is clamped into the layout’s own band rather than drawn by a schematic that never modelled it.

Where the engine’s rule needs a display, the preview stands a number in for the display — never a simpler rule. (#708, #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 — a Track schematic that grows the focused track to a drawn ceiling and piles the surplus, while the app fills and then spills into a new track (#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 (a rigid 8 × 1 at five windows piles two windows on the strip thumbnail and none in the panel, inventing an overflow the engine does not have — #712). 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, replacing the #125/#239 two-frame bar that once stood in UI patterns ▸ Previews & schematics). That bar 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. (It binds the FRAME, not motion: a picture that teaches a GESTURE rather than a rest state — the Shortcuts & Gestures drawer’s, argued under Shortcuts below — may move.)

A thumbnail that is read rather than compared — the welcome tour’s and the preset preview’s — may move too; A thumbnail that is read rather than compared, below, argues why the Layouts chooser still may not.

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 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.

A thumbnail that is read rather than compared plays its layout once (#1750, owner ruling 2026-09-28). The welcome tour’s Spaces step and the preset preview on Profiles each draw a layout thumbnail per Space, and a still frame there could not do what the tour promises — show how each row’s layout behaves, that one splits and another scrolls. So on those two surfaces, and only there, the thumbnail plays one short story and rests: a tiling layout gains one more window, which the engine places — BSP and Grid resting on four, every other layout on three, one ruled count both hosts share (LayoutStoryTests), lowered where the Space fits fewer (below) — Scrolling steps focus and pans, Monocle brings its next window to the front, and a Floating window is dragged and left where it lands. Each story plays once when its thumbnail first appears — staggered down a list — and again on hover, and never loops.

This does not reopen the ruling above, which retired a SECOND FRAME: a pair shows two states and leaves the tween to the reader, so it bought no motion and cost the strip its comparability. A tween does show motion, and it costs no width — every tile keeps its size. What separates the surfaces is how they are read. The Layouts chooser, its detail panel and the Home cards are compared at a glance while a draft changes, and they already use motion as feedback — a schematic restages when a setting moves — so a tile that plays on its own there would pull the eye to whichever one moved and drown that feedback. They stay at rest. The tour and the preset preview are read once, edit nothing, and name one Space per tile. LayoutStoryWiringTests holds the hosts to those two, and holds the story’s phase and its pace to the player, so no compared surface can be handed either.

The frames are the engine’s arrangement of arriving windows. A tiling story is not the Settings schematic tweened between two counts. A schematic draws a count, not a history: it numbers its windows afresh at each count and marks where the next would land with a + slot, so a tween between two of them swapped windows, faded one out, and let a pile’s cascade — an offset sized for a screen — push a window past the thumbnail’s frame. A story shows what the Space does instead: windows arrive one at a time through the Space’s own insert, each taking focus as a new window does, and LayoutEngine lays them out on a screen-sized canvas scaled down to the tile and clipped at the screen edge, where a pile hangs past on a real screen too (LayoutStoryArrangement, LayoutStoryCanvas). A window keeps its identity across the arrival, so only the newcomer and the windows making room move, and the newcomer grows out of its own slot. The picture therefore follows the user’s own settings — where BSP splits, where Stack puts the newcomer, whether Track opens a new track — and cannot teach a placement the engine does not make (the #702 rule). No story draws a +, Scrolling’s included: a story shows a window arriving, and where the next one would go is the chooser’s fact, not this one’s. Only Floating’s drag and Monocle’s step are drawn by hand: the engine claims no placement for either, and Monocle’s step pictures a window coming to the front rather than motion the app plays.

A story rests on what its Space fits (owner ruling 2026-09-28). The ruled count is a ceiling, lowered to the most windows the Space’s settings lay out without a pile (LayoutStory.restingWindows(for:settings:)): a 2 × 1 grid tells one window joining another side by side, not a fourth window landing on a pile. A pile is the layout out of room, the state least like the Space in use, and a story exists to show how this Space behaves. So the rest frame on these two hosts is not the chooser’s frame: the engine’s arrangement, no +, and possibly a lower count. The story always ends on it, and it is the whole of what Reduce Motion shows. It is still not a clip, for the reasons the Shortcuts & Gestures entry gives: it follows the palette and the appearance, carries no text to translate, and cannot go stale beside a redesign. Motion is never announced, so every story thumbnail speaks the layout’s behaviour as one sentence — the tour row and every preset preview tile alike, one register on one sheet.

Floating’s picture is scattered, everywhere. A cascade drew the one layout that places nothing as if it placed windows in a tidy diagonal. The scattered frame is what the story ends on, so Settings draws it too, and the two can never disagree.

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 a sidebar: the full window width for content, an entry screen that scales to however many cards are offered 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. (The sidebar shell was an HStack, never a NavigationSplitView, because macOS 26 cannot lock a split-view divider — a finding nothing rests on once no columns are composed.)

Home’s two groups are scope-named: “This Profile” / “Whole App”. (#678 turn 9, replacing #68’s “Design”/“System”.) Topical names exist because a sidebar label has to predict placement for a user who cannot 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 not a prediction the user must make — it is a caption over evidence. Scope is the primary navigation axis; 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.) 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, ScreenArrangement.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. There is no text-only fallback: a card without editor maths still draws its readout — on the plate (#786), for a This Profile card.

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.

Settings is one surface: depth grows in place, never by a mode. (#2654, owner ruling 2026-10-26, superseding #678’s Simple / Power User segment and #760’s reveal wash and mode-gated frame.) The segment gated whole cards per area. By its retirement, its whole effect was one card, Screens, on a Mac with one screen, plus six “first one” offers that already showed in Simple once the config used them (config presence, below). A header-level choice that changes almost nothing is a decision the user makes for no result, and the machinery serving it — the segment, the wash, the accent frame and its colour-vision guard, the search tag, the silent flip with its notice, the pop back to Home, a stored preference — outweighed what it gated.

Two alternatives were weighed and refused. A truly stripped Simple, a curated second menu, contradicts config presence (a user’s own layer cannot vanish from the smaller menu) and doubles the surface every change must maintain. Keeping the toggle keeps a visible decision with almost no effect.

What replaces it is the offer shape this file already rules. Each offer sits behind its own container’s disclosure (SettingTier.showMore), and the rows that exist once the config uses the capability are .immediate: the container opens on arrival for them and is never swapped (#1125). Layers and Modifier keys are collapsible cards; Lua bindings keeps its drawer. Where a drawer would hide one control, the offer takes the row’s own menu instead: title matching is always a choice in the App Rules float menu and a KiwiVision lens’s scope menu — a menu item already behind a click costs nothing at rest, and a drawer around it is chrome.

Per-Space Customize… is the one offer that left the menu (#2674, owner ruling 2026-10-27, amending this entry): every Space row carries a small icon-only Customize button, its badge counting that Space’s overrides, and the row’s right-click menu keeps Customize… as a second door. A right-click menu as a row’s only door is invisible at rest — the owner could not find it — while title matching is a choice inside a menu the user already sees.

Screens shows on any screen count (#2690, owner ruling 2026-10-27, amending this entry). Hiding its Home card on a one-screen Mac as a capability was refused: on a laptop a user would never meet the page at all. The card is live, not greyed: the page holds the pins and fingerprints of screens that are not connected, search and links already landed there, and no Home card greys. On one screen with nothing pinned, its subtitle invites a second screen instead of counting zeros.

The retired preference leaves its settingsMode key in the app’s UserDefaults unread. That key is a display choice, not a config or profile value, so the stored-value guardrail owes it no ConfigMigration; a stray key in the app’s own defaults domain is inert.

A persistent statement and a transient one never share a property. (#1173.) Hover shipped on the full-strength accent of the Home card’s border while #760’s mode marking lived on that edge, with the marking’s strength capped beneath it — and that shared property, not the strengths, was the defect: the two states differed only in degree, so a marked card read as permanently hovered, and pointing at one OVERWROTE 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 what a card is, 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 split holds with the marking gone (#2654): the neutral lift is the idiom the rest of the tree keeps, and the border stays free for a future identity marking.

One draft, three views — and the save surface is the floating pill. (#678 turn 9; Phase 4 shell.) 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. The popover opens from the pill’s own count line, not from a header chip (owner 2026-08-10): the pill already narrates the draft, so a second count in the header states one fact in two corners of the window — and every corner the count occupies is one the responsive pass must defend at 720 pt. A docked three-verb footer — defended as the pill’s final form, since the responsive pass docks the pill below 900 pt anyway — is refused (owner 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): 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. 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): 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 readout (SettingsValueReadout) narrates every attributed key, held total by its guard, 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’ keyboard board is 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 “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’ four group previews left under this same rule once #793 gave it a panel — 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 and Spaces it plans for — 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 that 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 page with no draft, whose rows write at once, takes the panel for a picture of its own live values — KiwiVision’s Default chip (#2103). The ruling above turns on the heading lying while the draft and the picture’s object differ; on a page whose every row writes gui.json the moment it changes there is nothing for them to differ by, since the live values ARE what a Save would hold. The panel’s width-driven fallback costs nothing there either: with the mode on, the desk behind Settings is the larger preview, so the picture is redundant with something on screen in exactly the way a panel preview should be. The carve-out is that narrow — a picture of anything else on such a page, a catalog entry or a stored file, still takes a sheet, and a page that grows a draft has its panel watch that draft like any other — on a KiwiVision lens’s chip, the draft’s lens.

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 does not contradict that page’s own reasoning: 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 takes some surplus at extreme widths is open.

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, each 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. Below 1200 it waits behind “Show preview” in every band: a card that opened with the screen covered the rows the user came to edit on every navigation (#1854). Opening it is per-mount, like the close, so a window grown back past 1200 docks the column again. So 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 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-signals and then under-delivers when a reader clicks 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 house icon-chip cue — a 0.06 rest fill — reads on device as “the grey doesn’t fit the kiwi design” (owner, 2026-08-24), 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 icon ladder rests at nothing too (#1393, owner 2026-09-24: the Profiles and Spaces trash read as a tinted container beside a bare one). Its 0.06 rest fill was the same colourless Color.primary surface the full row refused, only smaller: the fault is its hue, not its area. So a glyph-only icon control at a row’s end — trash, ✕-clear — rests as the bare glyph and shows the chip on hover (iconHoverChip), neutral as macOS’s own list controls are, the row framing it; a glyph standing alone beside text — the rename pencil, the add-screen-setup + — keeps the rest fill, since nothing else says it is a button, and the ? rests bare because its own circle is its shape (owner 2026-09-25). A Menu drawn as a bordered text button takes the neutral label on its LABEL: on the Menu itself the tint also fills the bezel, near-black. Every glyph takes ink2; a destructive glyph does not turn red on hover, since that promises an immediate delete a trash offering “here or everywhere” does not make. A chip-shaped control keeps its rest fill, which is its shape rather than a container.

The header’s size and its indicator are the two things a reader sees, and the hit target fixes neither. (#1021, owner 2026-08-25: “in some menus the accordion is way too small” and, asked which, all of them.) A chevron “with real weight” pinned at .footnote — the smallest step on the ramp — is the native triangle’s own defect (“drawn at the system’s own small size, which is what made it easy to miss”) in its replacement. And a header SIZE decided at the call site — Chrome carrying a font: payload — draws one component’s title at four tiers, seven of the fifteen drawers 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. .headline (13 pt semibold) reads as heavy (owner), 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 heavy. .imageScale(.large) on top of the inheritance makes 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 why size and indicator are a pair: their labels already sit at the tier the other ten take, so the tier change alone reaches nothing in them and the indicator is the only thing that moves there — which is why “bigger text or a better indicator” is a false choice.

“No fixed surface token can be a rest cue at all” holds for hairline too, 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. And a resting fill is refused on a second ground: 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 text that states what a shut drawer hides, drawn by hand at five call sites — .font(.caption), 10 pt against a 12 pt header, four of the five wrapping their own shut-only if — is the header tier’s drift wearing a different slot. One summary: slot 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. Fifteen drawers absent from the VoiceOver headings rotor is 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 requires it of a title component, and a drawer title is one.)

A collapsible container that is a peer of the page’s sections is a collapsible SECTION; one that qualifies a card stays a drawer. (#1741, ui-designer ruling.) Shortcuts & Gestures ▸ Mouse & trackpad is a family of its own beside the page’s other cards, and drawn as a .card drawer — the drawer tier, with no section above it — it read as a sub-drawer of nothing. So SettingsCollapsibleSection is a SettingsSection: the same plain .headline title and plate, with the disclosure as the plate’s first row — chevron and summary, the whole row one control — and the entries opening inside the card. The first cut put the chevron on the title outside the plate and left the summary a click target nothing announced; the owner found the card read as closed and inert (device look, 2026-09-29), so the affordance moved into the thing that opens. Shut, the card keeps that row and its place in the page instead of shrinking to a bare heading. This does not overturn #1021’s tier, which is about drawers — a drawer qualifies the card it sits in, and a header louder than that card’s own title inverts the hierarchy the other way. The test is the container’s relation to the page, never its size or how many rows it holds. GeneralShortcutsGroup meets it too and is still a drawer, left for its own change.

Both kinds draw the one SettingsDisclosureButton — chevron, full-row button, hover, the heading trait where the row is the heading, expanded/collapsed value and the Reduce Motion gate — so they differ in tier and chrome and in nothing the user operates. A second copy of that button is how the two would drift apart.

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 wrapping the whole label, accessory included, in the header button puts a control inside a control, which 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 rather than beside the title: 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, no in-window simulation can substitute, and its runtime effect cannot outlive the draft that made it — an effect in force but not saved survives the window that shows it, and the user learns a setting the next restart takes away (see “The recorder stages like every control” below). 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, App Rules, Shortcuts, 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.

Bar (a) is about owning no profile state, not about which file a control writes: General’s alert-sound and windows-per-pile rows clear it while writing gui.json, since what they write belongs to no profile (#1741, Spaces, profiles & config ownership).

Three save verbs: Revert / Save a copy… / Save. Seven differently-labeled verbs switching on invisible mode state express 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 duplicates it, reads as confusing, and its fixed width splits the button cluster apart. Adopt is not a save verb — it lives with the raw-Lua content it migrates. (#68 §3.12)

Every profile’s own rules stay editable, and the rule — not the dropdown — says which layer it writes. (#209, #1393.) A profile’s stored sparse overrides (app rules #109, key layers #55) are a shipped capability, so no edit target may leave a profile whose own rules the GUI cannot reach. That is the substance, and it outlives any one surface for it. What it rules out is the obvious simplification: map the loaded profile to the running config and stop there, and it becomes the one profile whose stored diff is unreachable — only the shared base edits.

The door belongs on the rule, because the choice is about the rule. Two dropdown rows for one profile — one writing the shared base, one writing its diff — keep the capability and hide the decision: the rows save identically on every page but App Rules and Shortcuts, and there the same “Spotify → Float” lands on every profile or on one depending on a pick made two clicks from the rule, with nothing on the page saying which. So the dropdown lists each profile once, the loaded one on top, and picking the loaded one IS the running config; its page shows the rules it resolves, the shared ones and its own. A profile that is not loaded says so in one line with Load, since its page cannot show its effect on screen.

Each App Rules row carries an “Applies to” checklist, and its semantics are the storage model’s, read aloud:

  • All profiles is the shared base rule, which a profile created later inherits. Ticked profiles are a list: each holds its own entry, and a later profile does not get it — ticking every box by hand is still a list, and the popover says so. Under All profiles no other box can be unticked, since “every profile but Home” is not the shared rule: giving only some profiles a new value means unticking All profiles first.
  • The ticks say who gets the row’s value, and nothing else. A value written with some profiles ticked reaches those; an unticked profile keeps what it had — the shared value, its own, or none — so “every profile keeps ⌃⌥T, Work takes ⌃⌥Y” is one edit. Unticking alone therefore changes nothing, and taking a rule away is the trash’s. The trap is “only these profiles”: it makes one profile’s own value an edit that strips the rule from every other, which is the opposite of an override (RuleReachTableTests ▸ sharedToList, untickAloneIsInert).
  • The edited profile’s box is ticked and locked. A page shows what its profile resolves, so a row that left its own profile would vanish from the page it was edited on. Removal is the trash’s, and where other profiles share the rule it asks Remove from Work or Remove from every profile rather than guessing.
  • A profile with a different value is warned, never blocked. The row names it (”⚠ Different in Home”) and ticking it gives it this row’s value; the save pill names both values. A block would let one profile’s stale rule veto an edit made for another.
  • A new rule starts at All profiles on the loaded profile’s page and at that profile alone on one that is not loaded — a shared rule written there can change the running layout from a page whose header says the profile is not loaded.

The ticks are derived, never stored. They are read from what each profile resolves over the shared base and its sparse override — or, while a Save is pending, from the draft’s own pick — so the files are unchanged and nothing migrates. That holds only because a Save re-encodes just the rows a change reached: an untouched row is written back exactly as stored, and a profile no change reached is not rewritten (RuleReachTableTests ▸ untouchedStaysAsStored, floatUntouched; RuleReachSaveTests ▸ uneditedWritesNothing). A changed row is written as the shared rule for All profiles, or as per-profile entries for a list. The trap is a stored “reach” field to make the ticks cheaper: it is a second answer beside the files to the question they already answer, and it goes stale the first time a profile file is edited by hand.

Shortcuts rows take the same checklist. A Shortcuts row is one action in one layer, its value the combo:

  • “Not here” is a removed combo, as App Rules’ is a tombstone. A profile’s shortcut override replaces a row per combo and also lists the shared combos it leaves out (KeyLayerOverride.removed), so Remove from Work marks Work alone and the shared row stays for every other profile and every later one. The two ways around a missing mark are rejected: carrying the shared shortcut into each other profile’s file means a profile created later never gets it, and rebinding the combo to a no-op still holds the combo and shows as a binding. A removal costs a Profile.currentFormat bump, since an older reader would otherwise drop it silently, and a removal whose combo the base no longer binds resolves to nothing and falls out of the next diff (KeyLayerOverrideRemovalTests). The removal is stored by combo while the row is one action, so a shared move re-writes it onto the new combo (RuleReachKeyLeftOutTests ▸ sharedMoveKeepsRemoval). A profile may leave out its own profile-switch shortcut: no trap follows, because the menu bar and Settings still switch profiles, and a rebind could always take that combo too.
  • Ticking a profile whose combo does something else takes the key over, and the warning names the action that loses it (⚠ Key is used for …); the takeover is written into the table, so the save pill and that action’s own row see it.
  • Clearing a shortcut other profiles share asks, as the trash does, rather than removing it from every profile.
  • A profile can move a shared action to another combo — its override removes the shared combo and binds the new one, and neither reaches the shared base (RuleReachKeyLeftOutTests ▸ movedInOneProfile, pageLeftOutKeepsBase). A file written before removals, with the moved row beside the shared one, still keeps its added row out of the base (RuleReachKeyTests ▸ movedComboStaysOut).
  • A stored profile’s own shortcut override stays overwriteProfile’s diff, since it carries layer structure (a new layer, an icon) the checklist does not; it is written after the checklist has written the shared base it diffs against. On the loaded page the shared layers are derived once, from the page’s own structure, and both the rule write and the globals write read that one base.

A layer takes the checklist too, and its tick is membership (#2022, owner ruling 2026-10-07). A layer is a set of rows, so asking which profiles a layer belongs to row by row hid the one question a delete or a rename turns on. The selected layer’s header leads with an Applies to line — the shortcut rows’ reach column’s look, one line that never stacks like the menu bar icon line under it (owner ruling 2026-10-08), App Rules’ closed label and popover — never a control on the chip, which would be a channel only a pointer finds:

  • A shared layer may be left out whole. The base keeps the layer and a profile’s override marks it left out (KeyLayerOverride.leftOut, the per-combo removal one level up), so every other profile and every later one still gets it (KeyLayerLeftOutTests). Converting a shared layer into per-profile copies instead was rejected: the profiles would stop sharing it silently, and it contradicts the row ruling that a removal is a mark, not a copy. The mark costs a Profile.currentFormat bump, as the row’s did, and no step.
  • Unticking a profile takes the layer out of it at Save, under All profiles too, with no dialog: the popover is the confirmation, and the save pill names each profile it leaves or joins. The edited profile’s box is locked — Delete is how a layer leaves its own page. A layer edit is laid over the stored files ahead of the key table (RuleReachSnapshot.rewriteLayers), so a row edit encodes against the layers the draft already has (LayerReachSnapshotTests, LayerReachModelTests).
  • Delete asks how far — this profile, or every profile that has it — unless the layer holds only the app-chrome rows, nothing switches to it and no other profile has it. Cancel is the default, so a reflex Return deletes nothing.
  • Rename never forks. It renames the layer, and its switch rows, in every profile the reading lists, and refuses a name any of those profiles already has; a profile left out of the layer is not reached and stays left out.
  • A new layer starts where a new row does: shared on the loaded profile’s page, that profile’s alone on a stored one (RuleReachDraft.defaultReach), recorded as the layer’s membership so the one layer pass places it — the page never adds or drops a layer of the base. A name another profile’s own layer holds is refused at Add, since the two layers would merge; the name of a shared layer the profile left out rejoins it on the loaded page and is refused on a stored one (owner ruling 2026-10-08), and a row added to a layer only some profiles have stays theirs. On a stored page a layer another profile shares is read but not renamed, deleted or joined — that is the loaded page’s, where the change is visible on screen — while a layer only that profile has stays editable.
  • A shortcut row’s checklist greys a profile without the row’s layer: ticking it there would build a layer of one row.

The saves write different layers, by design. The loaded profile’s Save and a stored profile’s Save touch disjoint field sets, because they edit different layers of the sparse-override model, not the same data twice:

  • The loaded profile’s Save (persistProfile → buildProfile) adopts the live tiling state (spaces, spaceModes, mainSpaces, fallbackSpace, settings) and preserves the profile’s stored layers, appRules, floatRules and ignoreRules. Those are sparse diffs against the shared base, and the checklist’s own write (KiwiCore.saveRuleReach) is what changes a rule diff — for every profile the change reached, this one included.
  • A stored profile’s Save (overwriteProfile → applyProfileEdits) writes that profile’s tiling and its key-layer diff against the shared base, and its rule diffs land as the checklist encodes them — one encoder for a rule family, never a second diff beside it. Ignore rules have no GUI control, so that hidden diff is preserved verbatim rather than reconstructed from resolved state.

The trap here is “fixing” buildProfile/persistProfile to also adopt the behavior overrides: that collapses the diff into an absolute and silently breaks the sparse override. ProfileSaveAsymmetryTests pins the preserving half so an 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 its Space rules are themselves per-profile-overridable, #109). 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 keeps claiming unsaved changes after the user has 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 end in reload() — which re-seeds from disk and clears isDirty — so ungated, six of them drop the user’s staged edits with no prompt at all. They 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 gate’s 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 path a hand-traced list had missed (the broken-profile Delete, #406). The guard covers only what it has needles for — adoptIntoGui, which had no token, was a reviewer’s catch, and 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.

Since #2049 that guard keeps only an OPEN window’s draft through a raise: a closed window has none to keep, because a close with unsaved edits asks Save / Discard / Cancel first and a close that did not ask drops the draft (Unsaved edits ask at the window).

A profile delete confirms even when nothing is staged (#1619): a profile holds a whole setup and nothing undoes its deletion, so a clean click must not be the whole gesture. Staged edits fold into that dialog’s message rather than a second dialog, so one gesture prompts once — Adopt’s reason. Return picks Cancel there, because the dialog is all that stands between a reflex keypress and a loss with no undo. The dialog is the shared one rather than a local one because two rows delete a profile (the healthy and the broken), and one pending slot is what makes “never two dialogs” hold. A Desktop binding’s × stays unconfirmed: it is one pick to set again.

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.

The third verb was decided for a close or a quit alone (#2049, Unsaved edits ask at the window), where leaving with the work unsaved is the whole question. It is a PendingDiscard kind of its own that runs the naming prompt, or offers the two verbs where saving is blocked; an action’s gate never takes it, so the reduction above still holds for every action.

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. The six gui.json globals — keybindings, app/float/ignore rules, the space list, Desktop→profile bindings — are collateral if saveGuiConfig’s one caller sits behind that gate: none of the six has a monitor dependency, so a user editing app rules meets a greyed Save explaining monitors, and loses 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 as new profile… on Live stays unconditionally gated — it captures the live set.
  • Its own narrow method. Routing through persist(named:) would drag in 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 appears only when a monitor set really is the only thing a save would write.

A picked layout is session-only, and Settings does not narrate it. Changing a space’s layout from KiwiHub’s view picker or a Space’s Layout menu updates the running state immediately and writes nothing: users try Monocle for ten minutes without rewriting their configuration. Keeping it is one press away — Keep on either surface — 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. Keep arms when ANY screen’s shown space stands on a temporary layout, not just the one the surface names, since the verb keeps every screen’s at once; a failed keep raises one alert whichever surface asked, because a menu has no surface to warn in and the two must not fail differently.

The profile has three write paths, and each writes exactly what its words say (#1179, re-ruled by #1790).

  • Keep = the layouts on screen. “Keep this layout.” It writes the mode of each Space the profile lists whose live mode differs from the saved one, on every screen at once, and nothing else: never which Spaces exist, their order or their pins, and never a temporary Space. It is the only thing that turns a temporary layout permanent.
  • save_profile = a whole-live snapshot. “Write down what is on screen” — the CLI’s Save as New. It takes every live Space, temporary ones included, and held ones never.
  • 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.

Keep was the whole-live snapshot until temporary Spaces (#1790) split the two. A row labelled Keep Layout that also wrote a scratch Space into the file, or dropped a Space the user had deleted for the afternoon, did more than it said, and the bar’s New Space made that the common case rather than the corner one. Which Spaces a profile has is changed deliberately (A Space made on the fly is temporary, under Profiles), so the row whose word is layout keeps layouts, and the verb whose word is save keeps the setup. Each moves an open draft’s saved baseline for exactly what it wrote and leaves staged edits staged.

Each half of the Settings Save 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 — never a temporary or held Space, which the draft does not hold.

“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 pickers’ “not saved to profile” — the view picker’s caption, the Layout menu’s 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 pickers’ job: switch back, or Keep.

Settings surfaces no temporary-layout drift. No pill row, no drift-armed Save and no caption under the Spaces mode picker narrates a standing temporary layout, and Revert is a plain draft revert. (The save pill’s live drift rows are a different question, ruled in The save pill counts what the header claims, #1197.) A Save enabled because of a temporary layout whose effect on it is identical to Revert is the thing refused; the one permanent path through Settings is the ordinary draft flow — Spaces, change the mode, Save. A picker’s 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 height matters (a row out of the column counts by its height; Simple’s grid of cards by position, #2777), 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 the two systems blur hardest: a row per Mission Control Desktop sits beside a list of KiwiDesk’s own spaces. Draggable profile chips would duplicate 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, where the caller takes text, any single character (incl. emoji) works via “Use as text”. One IconPicker serves layer icons, space icons and a Lua binding’s symbol, the last narrowed to symbols (below). (#68 §6.4)

Browsing is tabbed (Symbols first, every caller); search is global; every open starts in one resting shape. The picker’s popover splits Symbols and Emoji into segmented tabs, and a typed query searches both vocabularies at once (the tabs stand back, like Character Viewer). Symbols lead (#1379, owner ruling 2026-09-15, superseding #68 §6.4’s “Emoji first”): that reason — space icons are the picker’s most frequent use — said which destination to optimise for, not what that destination renders best, and the bar answers that itself. An SF Symbol identifier takes item_color / active_item_color / focused_item_color, while an emoji is untinted content the bar dims exactly as it dims a native app image (iconGlyph), so a space labelled with an emoji sits in the app-icon class and carries none of the bar’s state encoding. The layer icon rides the same ladder into the menu bar as a template image, so it wants symbols at least as much, and Recents is one shared list, so a per-caller default would leak through it anyway — hence no per-caller switch. Emoji stay one segment away, a user’s recent emoji sit above either tab, and search lists Symbol results above Emoji for the same reason, the two special results staying first. Search AND tab reset when the popover closes — a choice, the clear button or a click-away alike (#1357) — so no picker reopens on a filtered view the user did not ask for (IconPickerRestingShapeTests), and every popover holding a per-open search takes that same dismissal-edge hook.

A shortcut’s symbol picks from symbols alone. That rule is about the resting TAB; a caller whose value must be an SF Symbol narrows the VOCABULARY instead (#2544, symbolsOnly): a Lua binding’s symbol that names no symbol draws </> and raises a Config Issue, so an Emoji tab, a “Use as text” result or an emoji in Recents there would offer a choice that only produces that warning. They are removed, not greyed — no mode turns them on — and Recents stays one list, read through the filter.

The picker button and its clear control. The button shows a glyph-sized placeholder 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.

The Hyper key is a modifier KiwiDesk owns, and needs no Input Monitoring (#1870). The usual hyper key is an event tap rewriting Caps Lock into ⌃⌥⇧⌘. KiwiDesk runs no tap on a default install, so it instead remaps the chosen key to F18 with hidutil — per user, no permission, gone at a restart — and watches F18 with a Carbon hotkey, which hears both its press and its release. Only while it is held are the hyper+… shortcuts registered, as bare keys; at the release they go. So Hyper is a modifier of its own, not an alias for the four: a hyper+h collides with nothing, including an app that already binds ⌃⌥⇧⌘H. The key is the Mac’s, not a profile’s: it remaps a physical key, and a profile can change on a Desktop swipe, which would flip Caps Lock mid-sentence. No per-profile value preceded it, so hyper.key crosses nothing (#1741’s app-wide rule); it travels in a backup with the rest of gui.json, so a restored setup turns the same key into Hyper on the new Mac. KiwiDesk touches a key mapping only where it is exactly the one KiwiDesk recorded writing — Caps Lock → F18 is also a common Hammerspoon setup — and refuses otherwise. The trade: “tap Hyper alone for Escape” needs a posted key event, which is #1874’s to measure.

The one way past that refusal is the user’s own consent (#2471). An entry that is exactly KiwiDesk’s own for the chosen key — what a quit remapper such as the Hyperkey app leaves until a restart — may be taken over on a click, which writes the same boot-stamped record KiwiDesk’s own set writes, so quitting clears it. The shape decides only whether the offer appears, never ownership, and nothing takes over automatically: a live Hammerspoon config may own that entry, and two processes registering F18 both hear one press (measured 2026-10-20), so a take-over beside such a tool arms both. A mapping the other tool later removes is written again at the next apply; one it replaces is refused.

KiwiHub’s navigation keys take alternatives from Lua, never a Settings row, and they are the Mac’s (#2600). A user whose hands stay on the home row wants ⌃N/⌃P beside ↑/↓; the built-in keys keep working, so an alternative adds a chord and replaces nothing. The panel reads them after every built-in key, and the verb refuses a chord the panel or its field already answers, so no alternative can take a key away. They are app-wide because they are muscle memory, not a desk: stored per profile, a Desktop-bound switch would drop them mid-task. No per-profile value preceded them, so kiwihub.key crosses nothing (#1741’s app-wide rule). They are the first app-wide member with no Settings row, which costs one trade: adopting an init.lua setup into Settings writes its keys into gui.json, and nothing in the window changes them after. init.lua’s kiwihub.set_key still sets them for the session at every load, and Reset clears them.

Left and right modifiers are told apart by one keyboard tap, and only while it has something to do (#2601). Carbon cannot tell the sides: measured on macOS 27 (2026-10-25), a ⌘J hotkey fired for both ⌘ keys with identical modifiers, Carbon.h has no right-⌘ constant, and a rightOptionKey mask was dropped, registering a bare K that fired alone. A keyboard CGEventTap reads the side from each key event’s flags (⌘ 0x08/0x10, ⌥ 0x20/0x40, ⇧ 0x02/0x04, ⌃ 0x01/0x2000), and an active tap and a listen-only one both ran with Accessibility alone: the probe was under no Input Monitoring grant, and both were refused before Accessibility was granted.

So the switch Tell left and right modifiers apart (Shortcuts & Gestures ▸ Modifier keys, set_modifier_sides) admits the tap, bounded three ways. Every either-side shortcut stays a Carbon hotkey, save one on the same keys as a sided shortcut, which moves to the tap with it so the two never race for one press. The tap runs only while the switch is on and a side-specific shortcut exists, so a default install, and one that never records a side, sees no keystroke. It swallows only a press matching one of its shortcuts and passes every other key on, recording and storing none of them, and it re-enables itself after macOS disables it (tapDisabledByTimeout, or by user input), since macOS disables a slow tap silently; a tap macOS refuses hands its shortcuts back to Carbon, either-side. A shortcut the tap answers does not glide, since the tap hears no release. The recorder reads the side from the key event’s own device flags and needs no tap.

The switch is the Mac’s, in gui.json beside hyper.key: a keyboard is a desk’s hardware, not a profile’s. No per-profile value preceded it, so it crosses nothing. Switching it off migrates nothing — a stored rcmd+h stays rcmd+h and fires from either side through Carbon until the switch is back on — and a pair that falls on one chord (lcmd+h and rcmd+h, or cmd+h and rcmd+h) is a conflict tier of its own, whose sentence names the switch as the way to separate them. While the switch is on the recorder records no either-side shortcut, save under a held Hyper and on the other side of a one-sided Hyper key, which only one key can produce: one is recorded with the switch off, or written in Lua as cmd+h. Accepted.

What it unblocks: reading keys needs no Input Monitoring, so #1874’s “tap Hyper alone for Escape” turns on whether posting the Escape does, which is #1874’s to measure.

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.

KiwiHub’s Shortcuts side lifts those rows out instead, into an Unavailable shortcuts band at the end of its column (owner ruling 2026-10-16). The reason Settings keeps them in place is that Settings is an editor: a row stays where its catalog puts it so it can be rebound there. The hub is a read-only list of what a key does now, so a group that mixes working keys with keys that do nothing answers its one question wrongly. The band is not the Inactive one, for the first reason above — its caption promises the key still fires — and it takes the caption Settings puts over the same rows, so both surfaces say the same thing about them.

Config presence expands the surface. A first-week Settings 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 — is drawn on arrival; what a disclosure withholds is only the invitation to create the first one, and the disclosure opens on arrival the moment that invitation is accepted.

The failure this prevents: a user creates a layer, then finds the Layers card folded away on the next visit. Hiding a control the user has never used is curation. Hiding something they made is losing their work as far as they can tell. Nothing a user has created — a layer, a Lua binding, an override — is ever folded away on arrival.

It follows that “how deep is this row” 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). Moving a window to a macOS Desktop is the worked case. Nothing seeds it, a Desktop is macOS’s arrangement rather than KiwiDesk’s, and its rows scale per Desktop — so a four-Desktop Mac would draw eight move rows about a thing a first-run user has never met. They sit behind a disclosure in the macOS Desktops section (#2655, below), which stays mounted and simply OPENS on arrival once a move is bound — 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.

Two things this is NOT, each considered and refused. 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, a drawer in Move windows, and it settles a thing 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 a user can meet a Track space without ever having chosen the word, so that user must meet the drawer open. #1440 also ranked it below a Desktop drawer in the same card, by audience size; #2655 below supersedes that ranking, and the Track drawer is the card’s only one.

The Desktop verbs share one always-mounted macOS Desktops section (#2655, owner ruling 2026-10-26), superseding the two Desktop drawers above. Seeding Go to Desktop (2026-10-10, below) broke the drawers in both directions. Every install now records a Desktop verb, so the area-wide unlock opened both drawers on every visit: the Move drawer, meant to stay an offer, opened only because a Go-to row existed, and since the open state lives for one visit, a collapse the user chose never stuck. For the users the seed does not reach — an init.lua binding no Desktop verb, rows deleted — the drawers stayed shut on a capability that works with no setup. The drawer was chrome for most users and a hiding place for the rest.

So Shortcuts carries one section, macOS Desktops, after Move windows: KiwiDesk’s own Spaces lead and a Desktop is the way out into macOS, which the old in-card order already said. Go to Desktop sits at rest; Move window to Desktop and Move and follow, interleaved, sit behind the section’s one drawer, so a four-Desktop Mac shows four rows at rest rather than twelve. That drawer opens on arrival only when a MOVE is bound: counting the seeded Go-to rows would reopen it on every install again. The name carries the noun the help sentence and the Profiles card use, a macOS Desktop, and the section’s title takes the old drawer titles’ place as the families’ search name, their rows’ labels being dynamic.

What #1125 argued for the door still holds and is kept. The drawer is permanent and never swaps for a bare list, so recording the first move inside it does not tear the recorder down. Nothing gates on how many Desktops the Mac has. And where the families expand to nothing — no window-management bridge, no reachable Desktop — the section is removed rather than greyed, and search refuses its two names on the bridge question, since a grey would invite an action with no path. What is retired is the door per card and the unlock shared across them: one section has one door, and a Go-to row no longer answers for the moves.

It also reverses #1440’s ranking. That ruling ordered two drawers inside Move windows by audience — Desktop rows apply to everyone with macOS Desktops, Track rows to one layout — and set aside “KiwiDesk’s own first” as the alternative. Once the owner ruled one place for every Desktop action, the Desktop rows leave the card altogether, and the question becomes where a whole section sits on the page. There the page’s own order decides, Spaces before the way out of them, and audience size has nothing left to rank inside Move windows.

The Desktop switch cue moves with them, to the section’s first row (amended the same day). It marks a switch KiwiDesk itself performs, which is what these shortcuts are, so it shows only for a user who presses them, and that user sets it up here. It keeps its own gate: it greys, with its sentence, only where init.lua owns the config, apart from anything that greys the shortcut rows. Looks & Animations was the other candidate and lost twice: that card greys under Reduce Motion, where the cue must keep working since it replaces the motion, and a look is per profile while the cue is app-wide. Profiles ▸ Desktops, left with the bindings alone, stays collapsed while nothing is bound (2026-09-24).

Overrides always resolve; no display setting changes what runs. There is no stored flag deciding whether a profile’s shortcut overrides apply (a redesign draft specified one, with a default and an upgrade migration): 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 how it is shown.

What the surface may decide is only where creating a first override is offered — behind a disclosure or in a row’s menu — never whether an existing one shows. 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 unlock becomes something users trip by accident and stop trusting. ShortcutsCapabilityUnlockTests holds both halves. 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 display 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, which #2654 later retired — 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. The rename shipped before AGENTS.md §5 ruled that a stored key owes a ConfigMigration step, so it carries none: an old "modes" key reads as absent rather than as layers, and re-saving is the migration. It is no precedent for a rename today.

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 rewrites its switch-layer rows through the catalog’s single authority, so writer and import classifier keep matching byte-for-byte (#4), and reaches every profile that has the layer (▸ Navigation & saving, A layer takes the checklist too). 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.

With the Space Bar off, the menu bar item is the Space Bar’s stand-in (#1413, owner ruling 2026-09-16). A user who turns the bar off to keep the screen loses the one place that says which Space they are on, and the request was for the menu bar to say it instead — the way AeroSpace names its workspace in the menu bar. Three rulings shape it. It is composed the way the bar composes, <layer> | <space>, rather than replacing the logo with the Space’s icon: the layer’s glyph already lives there (the entry above), so the two facts share the item as they share the bar, the layer’s glyph leading only while a layer other than default is active. The Space shows its icon where it has one and its full name where it has none — no brand-logo fallback, since a Space always has a name, and no two-letter monogram, since the menu bar has room for a real name the bar’s square cell lacks. And it is not a setting: the bar being off is the condition, and with the bar on the item keeps its brand or layer icon exactly as before — a switch would cost what the entry above prices, for a choice nobody has asked to make the other way. One item lists EVERY screen. A macOS status item is one item mirrored into every screen’s menu bar, so it cannot show each screen its own Space; it lists the Space each screen is showing instead, in desk reading order, one divider between each — the same reading the Space Bar’s per-screen strips give side by side. The drawing is ONE image so the #1013 update mark composites on it as it does on the brand icon, a template unless an emoji is in it — an emoji keeps its colour at the cost the mark already pays, no highlight inversion while the menu is open. Core hands the structure (StatusSpaceMark, published off the bar’s own refresh so every trigger the bar has reaches the item) and the GUI draws and names it; the name says the layer and each screen’s Space, so VoiceOver reads what the eye does.

“Off” became Auto and Hidden with #761, and the stand-in follows the edge’s own fold (owner, 2026-10-20): a screen is listed wherever its Space Bar is not drawn at rest — Hidden, or Auto unless an Always bar shares its edge — since the menu bar is then the one place that names the Space until the pointer reveals the bar.

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 forms chords on release is buggier in practice than the one-click re-record it buys. 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 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 the row reads the same bit to pick its TIER (#1126): 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) are dead rows too — macOS opened the app switcher and Force Quit while a bound control chord proved the probe heard presses (#1275, 2026-10-10) — with no setting to switch them off. 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. The “Assigned to…” row carries 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 in #213 pending a design round — the Steal/Go to hard-block is the conflict UX until then.

The recorder stages like every control; only its suspend is live. A recording, a clear or a deleted row changes the draft, and the running hotkeys are rebuilt from the saved files alone (ShortcutsApplyOnSaveTests holds the census of who may write the running table). The argument is what a live-but-unsaved chord outlives: the window that shows it is unsaved closes without a revert, a restart shows no prompt, and a config reload drops it, so the user builds muscle memory on a shortcut that vanishes later, far from the edit that made it. No native Mac app has a setting that is in effect but not kept — System Settings ▸ Keyboard Shortcuts applies and saves in one act — and one save pill cannot say both “not in effect yet” and “in effect but not kept”. Reverting the live copy when the window closes was weighed and refused: closing Settings would silently undo a chord the user just pressed, and a chord left unsaved in an open window still dies at the restart. What stays live is the suspend while a recorder is armed (#213): it exists so a chord already bound to KiwiDesk is captured rather than fired, and disarming resumes the saved table. What a recording can still tell before Save — a duplicate in the layer, “Assigned to…”, a macOS collision — it tells; trying the chord is record, Save, press.

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 (#9).

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, because the codebase falsifies that version — L() is @MainActor, but so is KiwiCore, which can call it (#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. Unseeded, a fresh install boots with zero shortcuts (the default layer exists but is empty): a GUI-first user has no way to focus or move a window until they author every combo. 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; the chord is amended below, in ⇧ has one meaning) — plus ⌥⌘ 1/2 and 4/5 for size on a base of its own (#1075) — 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 (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 (a 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. Finder binds ⌃⌥⌘1–7 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 in SystemShortcuts.map, so the editor can say why rather than failing mute on both sides. SizeLayerSeedTests ▸ knownShadows is the one copy of that exemption, and it reds if the chord ever stops being reserved.

The verdict does not guess at that population split (#1105): 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 — 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: f1–f12, 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. 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 f1–f12 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. Parked on tier 3, ⌘ means two unrelated things at once — resize with an arrow, “and follow” with a digit — so the ladder cannot be stated in one sentence. #1176 below gives ⌘ a second POSITIONAL sense on purpose: what it may not carry is a verb from another idea, which resize is.

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 lands on ⌥⌘. Those two are different arguments: moving the non-positional TOGGLES onto ⌥⌘ reasons from the taxonomy, which is not 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 ⌃⌥.

Next layout is ⌃⌥C (#453), the first prediction the rule paid. Stepping a Space to its next layout is a verb you tap, so it takes a mnemonic letter on ⌃⌥ — C for cycle — beside the toggles. It goes in the base seed, not appChromeRows: it acts on a Space, and a GUI-created layer re-seeds only chrome. Previous layout takes ⌃⌥⇧C (owner, 2026-10-11): #1094 keeps ⇧ off a lettered toggle because there it read as “act on the window”, but a letter whose unshifted row STEPS has a twin to reverse, the second sense #1655 gave ⇧ on Tab. A toggle still never takes it. The collision measure (2026-10-11, recorded on #453): SystemShortcuts.map, com.apple.symbolichotkeys, sixteen apps’ menus read through AX with the fn bit decoded, and the Xcode, VS Code and JetBrains default tables found nothing on ⌃⌥C or ⌃⌥⇧C (JetBrains’ Ctrl+Alt+Shift+C becomes ⌥⇧⌘C on a Mac). VoiceOver’s VO-C and Emacs’s C-M-c under Option-as-Meta are the class the whole ⌃⌥ tier already accepts.

The layout picker takes ⌃⌥L (#762), L for layout, beside the cycle’s C: each letter names what its key does, so the two carrying different letters is intended. It is a layout verb too, so it joins the base seed and not appChromeRows. The same sweep found ⌃⌥L free; the nearest chords taken are Terminal’s ⌃⌥⌘L and the ⌥⌘L Downloads item Finder, Safari and Chrome keep.

The window switcher takes ⌃⌥J (#1875), J for the Jump its panel does. It opens a panel rather than acting on a window, but a pressed one, so it joins the base seed beside the layout keys and not appChromeRows. The same sweep found it free in macOS and in every app menu read; its one app collision is VS Code’s notebook “Join With Next Cell”, bound only while a notebook cell has focus, where the panel wins. It reaches an install in a generation of its own (3), since installs built from release/3.0.0 had already taken generation 2.

Reset sizes in this Space takes ⌃⌥R (#2303), R for reset. It is the way back from the size keys, but a key you press once, so it rides the ⌃⌥ letters rather than ⌥⌘: it joins the base seed, not appChromeRows. It resets the active space only, for the reason the #764 entry gives the verb’s default: a key undoes what you just did in front of you. "all" gets no Settings row and no default key; it stays a Lua and CLI form (owner ruling 2026-10-18). The verb is in APIReference.destructive, which makes an AI app confirm it, and a default key does not contradict that: the register guards a call the user did not make, and a key press is the user’s own. What one press loses is the hand sizing of one Space, which the resize keys rebuild. A default key for a verb in that register remains a ruling of its own. The 2026-10-18 sweep found ⌃⌥R free in SystemShortcuts.map, in macOS’s symbolic hotkeys and in the menus of every app running on the dev Mac, read through AX with the fn bit decoded (recorded on #2303); the nearest chord taken is macOS’s own fn⌃R, Return to Previous Size.

A shortcut row says what pressing it does (#2245). Its title is a verb phrase: “Show” and the panel’s VoiceOver name for a panel — “Show window switcher”, “Show layout picker” — “Open” for a window, and “Switch to next layout” rather than a bare noun. “Show” and not “Toggle” is Apple’s own pattern for a key whose second press dismisses the surface (“Show Spotlight search”, “Show Notification Center”). The panel’s verb stays “Jump”: the Windows side’s field prompt and the footer’s ↩ hint say it, and J backs it up for an English reader. What links a key to its side in every language is position — ⌃⌥J, ⌃⌥K and ⌃⌥L sit side by side on the home row — and the chord the scope switch prints beside each side, read from the live binding. A word mnemonic links only in English, so G K L for “Go to” was dropped, and the side names stay translated rather than kept as English words that read as missing translations.

One pressed exception rides ⌥⌘: the Space history on Tab (#1655, owner ruling 2026-10-09). Back and Forward through the Spaces you visited are pressed, so the rule above sends them to ⌃⌥. They take ⌥⌘Tab and ⌥⌘⇧Tab instead, for the hand: ⌘ and ⌥ sit on the right of every Mac keyboard, while a MacBook has no right ⌃, so a key pressed in passing — the way ⌘Tab is — reads as one thumb roll on ⌥⌘ and a two-hand reach on ⌃⌥. ⌃⌥Tab was the other candidate and lost on exactly that. The prediction survives because the exception is pinned to one key: ⌥⌘ still carries no pressed verb on any other key, and SizeLayerSeedTests holds the pair to Tab, so it cannot spread under cover of the first. Before seeding, the owner pressed ⌥⌘Tab (2026-10-09): macOS’s app switcher does not take it, and SystemShortcuts.map reserves ⌘Tab alone.

The Desktop tier is seeded: ⌃⌥⇧⌘ + digit goes to that macOS Desktop (owner ruling 2026-10-10). Going to a Desktop is the one Desktop verb a user presses as often as they swipe, so it is the exception to the offer rule above: it ships bound, at rest in the macOS Desktops section (#2655), while moving a window to a Desktop, with or without following it, stays an offer behind that section’s drawer for the reason that rule gives. ⇧ here does not act on the window: on top of ⌃⌥⌘ it lifts the whole ladder one tier, from KiwiDesk’s Spaces to macOS’s Desktops, which is the second sense of ⇧ the rule above otherwise refuses — accepted because the four modifiers read as one chord (the Hyper key many users map Caps Lock to), and the ladder has no other free tier for a digit. Measured before seeding (2026-10-10, macOS 27.0.1): no entry in com.apple.symbolichotkeys uses the four modifiers, and over the AX menu bars of fifteen running apps (858 shortcut items) one claimed a ⌃⌥⇧⌘ digit — Antigravity IDE’s Split Terminal on 7, which KiwiDesk’s hotkey takes from it as every seeded chord takes an app menu’s. #1870’s Hyper key does not move this tier unasked, and the seed stays here: it is a separate modifier and off by default, so the seed cannot depend on it, and the users the four-modifier chord serves are the ones whose own tool already presses all four.

One switch puts KiwiDesk’s shortcuts on ⌃⌥ or on ✦ (#2460, owner ruling 2026-10-20). The Modifier keys card carries “Shortcuts use: ⌃⌥ | ✦”, and its value is one stored base, gui.json’s shortcut_base, which every writer of a default row reads: the seed, Restore Defaults, and the three top-ups — a new Space’s digits, a new Desktop’s row, a default added in a later generation. Choosing ✦ moves every row whose modifiers are ⌃⌥ plus at most ⇧ and ⌘, swapping the base and keeping ⇧ and ⌘ (⌃⌥F → ✦F, ⌃⌥⇧← → ✦⇧←, ⌃⌥⌘3 → ✦⌘3). The ⌥⌘ size tier and the Space history stay where they are.

One base is the point. Three sources used to decide where a new row went: the Desktop base, a guess from where the seeded letters sat, and a fixed ⌃⌥ for a new Space’s digits. So a user who moved the ladder to ✦ by hand had it split again at the next new Space, and “my shortcuts are on Hyper” held only for the rows that existed on the day they moved them. A base every writer reads keeps it true for rows that do not exist yet. The one-shot move offer that came before (#1870) and the letters guess (#2303) retire with it: an offer covers the rows of its day and leaves every later writer guessing, and a stored answer is what a guess stands in for. So Reset sizes in this Space (#2303) and every default added later land on the base like the letters, and the Desktop tier moves only with the switch.

Go to Desktop is the one exception, and takes ✦⌥ + digit. A Hyper row is registered bare while the key is held (#1870), so ✦⇧⌘3 IS ⇧⌘3, macOS’s screenshot — and macOS answers it first: pressed on device (2026-10-20, macOS 27.0.1), the screenshot fired and the row never heard the press, as #1126 predicts for every live symbolic hotkey. The whole tier moves, not only the screenshot digits, since a tier with holes reads as a defect. ⌥ + digit holds no symbolic hotkey and no KiwiDesk row, and on ✦ the ladder then reads one modifier per tier: the digit alone goes to the Space, ⇧ sends the window, ⌘ follows it, ⌥ goes to the Desktop. Refused: ✦⌃ + digit, which is macOS’s own Switch to Desktop and fires twice for anyone who turns that on; ✦⌥⌘ + digit, which is KiwiDesk’s own size tier while Hyper is held; and leaving the tier on ⌃⌥⇧⌘ under ✦, which splits the ladder across two bases — the split one base exists to prevent — and leaves a row in the list below that no re-recording clears. The mapping names this tier as its one case, so the way back reverses it.

The base is app-wide, with no profile checklist. A per-profile base was weighed and refused: a swipe to a bound Desktop would swap the whole keymap between ⌃⌥ and ✦, and every top-up would write synced profile files on every new Space. A profile that wants one row on ⌃⌥ keeps it through that row’s “Applies to”. The base is drafted with the layers rather than written at once like the other app-wide values, because Save and ↺ have to move the rows and the base together: a base written at once beside a reverted move is the split again.

What the switch moves, and what it leaves. It moves the shared keymap and the loaded profile’s own rows. A row the user rebound off the base stays where they put it, and a target another row of the page holds is refused and named with its holder, since nothing KiwiDesk writes gives an action a second chord in a layer — a holder in another profile’s own rows is not looked for, the residue of moving a keymap that profile only inherits. A shared row a profile leaves out moves too, and the mark that leaves it out moves with it (owner and ui-designer ruling, 2026-10-20): a profile names a left-out row by its chord, so moving the row alone would bring it back there, while leaving the row behind split the shared keymap for every other profile over one profile’s choice. The same holds for a profile whose own row took the moved row’s old chord — it went without the shared row, and still does on the new one. Every row still on ⌃⌥ is listed under the switch with its reason — the row predates the update, another row holds the key, the user set it, or another profile sets its own — and is re-recorded by hand; there is no “move these too”, since each reason is one the user should read before the row moves. ⌃⌥ moves back what the mapping can produce, derived from the pure reverse mapping rather than a stored list: a row on any other ✦ chord stays (✦⇧⌘3, ✦⌃F), and nothing that came from ⌥⌘ lands on ⌃⌥. A row the user recorded on ✦F themselves moves back with the rest — telling it from a moved one would take the stored list the ruling refused, and a list that outlives a re-recording is wrong the first time the user edits a row.

Without a Hyper key the switch is drawn greyed on ⌃⌥, since the setting that turns it on sits directly above it in the same card (gui.md ▸ Grey, don’t hide). Clearing the key while rows are on ✦ moves nothing back silently: the rows dim in place, and while the base is ✦ the switch stays live as the way back.

The stored Desktop base this replaces covered one tier and the new base covers every one, so the crossing is a change of meaning under a renamed key and reads the file’s format to end (#1354). It reads ✦ where the user took either half of the old offer — the Desktop line, or most of the seeded letters — since either was a choice of Hyper, and splitting the Desktop tier from its own next row is the defect one base exists to prevent; the letters count is a guess made once, inside the crossing, and never again. A Go to Desktop row on ✦ + digit moves to ✦⌥ + digit where that chord is free, since the Space digits now hold ✦ + digit. Nothing else moves on its own; the ladder rows still on ⌃⌥ appear in the list.

The rows are topped up rather than seeded once, because Desktops come and go: at boot, and at a switch that shows a Desktop numbered past the setup’s mark, each such Desktop up to the tenth gets its row when no row goes there and the chord is free. The mark is what keeps a deleted row deleted — a Desktop at or below it was offered before — and it lives in gui.json (#2324): the rows it vouches for are the setup’s, and a Mac sharing that setup must not re-add a row the user deleted on another. A Mac with more Desktops still gets its extra rows, since its count is past the mark. Every write keeps the larger mark, so no Mac lowers it, and discarding gui.json — Reset All Settings — takes it along. A restored backup carries its own mark. A Lua-owned setup leaves the mark alone, so moving to Settings later still offers every Desktop. Restore Defaults restores the tier for the Desktops the Mac has.

Open Settings is seeded on ⌃⌥, (#1381, owner ruling 2026-09-13). A menu-bar app with no Dock tile is almost never the active app, so ⌘, reaches a new user only once they have found Settings some other way. The chord is app chrome, so it rides the pressed base beside ⌃⌥K, and it takes the punctuation ⌘, already trained rather than a letter that does not say Settings (⌃⌥O, ⌃⌥G were free and set aside). ⌃⌥Space is macOS’s input-source switch and ⌃⌥S is Toggle sticky, whose move would owe a migration. Measured before seeding, since SystemShortcuts.map is necessary and not sufficient: over the AX menu-bar tree of twelve running apps (2026-09-15, macOS 26.6.2 — the same sweep #1176 below describes), comma sat only on ⌘, and ⇧⌘,. The row binds the key CODE (43, the ANSI comma position), so it is reachable on every board; where that key prints another character the board shows that character, the trade every lettered default already makes. Which population has it follows the seed’s own rule below: a new setup, or an existing one through Shortcuts & Gestures ▸ Restore Defaults…, which lands it in the default layer; a layer made before this carries it only by hand.

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 with size on its own base it is free of KiwiDesk’s own verbs. 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 says. 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. 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. The sweep reads EFFECTIVE accelerators, so it cannot tell a shipped default from the reader’s own override: the one ⌃⌥⌘+arrow hit the sweep 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 a reader that ignores the fn bit reports every app claiming ⌃⌥⇧+arrows. 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⌃←.

Swap returns to ⌃⌥⇧, and ⌃⌥⌘+arrows steps the Spaces (#1655, owner ruling 2026-10-09, amending #1176 above). The previous and next Space need a chord, and the obvious base is the one the ⌃⌥⌘ + scroll step already taught (#1519): a gesture and a key that do the same thing should share their modifiers. That base held swap, so swap moves — to ⌃⌥⇧+arrows, where ⇧ means what it means on a digit, “act on the window”: ⇧ sends the window to a Space or swaps it with a neighbour, ⌘ steps through the Spaces or sends the window and follows. The grammar #1176 gave up for ergonomics comes back, and the cost #1176 named returns with it: ⌃⌥⇧+arrows is the three-modifier claw for the less frequent of the two verbs, which the Space step — pressed far more often than a swap — now does not pay. ⌃⌥⌘↑/↓ are left unbound rather than given a verb nobody asked for.

⌥⌘+arrows was refused for the Space step. It is the lighter chord and it is next/previous tab in Chrome, Edge, Arc, Firefox and VS Code (#1075’s boundary above): a global hotkey pre-empts the frontmost app, so the step would take tab switching away everywhere, a loss with no menu path that presents as “the browser is broken”.

The collision measure, and what it leaves owed. SystemShortcuts.map reserves none of the four new chords — the ⌃⌥⌘ arrows, ⌥⌘Tab, ⌥⌘⇧Tab — nor the ⌃⌥⇧ arrows swap moves to (SizeLayerSeedTests holds every seeded row against it). The app-menu enumeration of 2026-09-04 above found neither arrow row in any app’s shipped defaults, read with the fn bit decoded. The menus have not been read for the Tab pair; that sweep is owed on the owner’s desk before the release, and the register check is necessary and not sufficient (#1098).

Nobody is moved. As with every later default, an existing install keeps its keymap: its swap stays on ⌃⌥⌘+arrows. The four new rows reach it through the generation top-up below, each only where its chord is free, so the history pair lands and the steps wait on that swap until a Record or Shortcuts & Gestures ▸ Restore Defaults… moves it.

A default a release adds reaches an existing install once (#2211, owner ruling 2026-10-12). The seed is written at first run, so a row added later was invisible to everyone who updated, including a spotlight feature’s own keys. Each such row joins a numbered generation (DefaultKeybindings.rows(ofGeneration:)), and the first launch after an update appends the rows of every generation past the setup’s mark where the action is unbound and the chord free, then advances the mark — kept in gui.json beside the Desktop tier’s, so one Mac’s offer is every Mac’s (#2324). Adding is not moving: an occupied chord keeps its binding, and a row deleted after its generation was offered is never added back. A fresh seed carries every generation, so it tops up nothing.

Space history walks the visits, never the order (#1655, owner rulings 2026-10-09). Back and Forward answer “where was I”, which the order steps cannot: the Space you came from is rarely the one beside you. They behave as a browser’s buttons, because that is the model everyone already has. Every arrival is a visit — a key, a Space Bar click, a move-and-follow, a Desktop switch that brings a bound profile’s Space — since a history that skips some arrivals sends Back somewhere other than where you were, and “it depends how I got here” is the one answer the verb cannot give. Back and Forward move a cursor and never push; any other arrival pushes and clears the forward half. They never create a Space: a visit to a Space that has since gone is skipped, and a held Space renumbered on a screen change keeps its visits.

Per screen by default, all screens on request. With a Space per screen, “the Space I was on” usually means on this screen — a Back that jumps the focus to the other monitor is a surprise — so each screen keeps its own history and Back acts on the screen of the focused window. Someone who works across screens as one desk asks for the other reading, so it is a setting, per_screen or all_screens. A third choice, a back-and-forth toggle between the last two Spaces, was drafted and dropped: Back then Forward already bounces between them, and the first Back is the same in every kind, so it bought nothing a user could feel. Both histories are recorded all the time, so changing the setting loses nothing.

Stored like the shortcuts. The setting is a global base in gui.json that every profile uses, and a profile may keep its own as a sparse override — the scroll gestures’ shape (#1656), and for the same reason: it changes what the keys on the same page do, and a profile is where a user who docks and undocks keeps what differs. An app-wide value with no override was the first draft and lost on that ground. Edited for a profile saved for one screen, the row greys and says why — both choices walk the one history there — while the shared value never greys, since it serves every profile.

It starts fresh at every start. An in-place restart, an update or a crash carries no history. A Back that lands on a Space from before the restart, chosen by visits nobody remembers, is a surprise rather than a convenience, and carrying it would make the history one more store the snapshot must keep coherent with renumbered and dropped Spaces for no visible gain.

⇧ 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. Global sticky is ⌃⌥S (S still leads the label a GUI-first user is shown, “Toggle sticky everywhere”), and the screen-scoped one ⌃⌥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.

Amended by #2446 and #2611: ⌃⌥P belongs to Pocket, P for the pocket, and the screen-scoped toggle keeps no default chord. It also leaves the Settings shortcut list, the one row #2611 moved to Lua-only: an action with no default that few people bind costs every reader of the list a line, while the Lua bindings drawer keeps it bindable. The move reaches existing installs once, through a stored-binding step that rewrites only the seeded chord still running the toggle, in a file below the format that step introduced — so a chord the user puts back on it afterwards is theirs, and a chord they gave it on another key was never touched. The seeded chord is read on the file’s own shortcut_base, ⌃⌥P or ✦P, where the file carries one (gui.json, a backup’s config). A profile file carries no base, so there both ⌃⌥P and ✦P move: the trade is that a pin a user bound by hand to the other base’s chord inside a profile moves with them.

Amended again by #2675 (owner, 2026-10-27): on a letter, ⇧ is a related variant of the unshifted key, not a ban. It may reverse the key (⌃⌥⇧C, the layout cycle backwards) or add a closely related act on the same subject: ⌃⌥⇧P marks the window ⌃⌥P pockets. What #1094 retired stays retired — ⇧ turning a letter into something about a different subject or scope, as ⌃⌥⇧S did — since that is the reading a user who learned the ladder gets wrong; a variant on the same subject reads the way the plain key taught.

D, the obvious letter, is refused on 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 ⇧ means exactly one thing everywhere, and so does ⌘.

Amended since: off a positional key ⇧ reverses its unshifted twin — Tab (#1655), and a letter whose unshifted row steps, the layout cycle’s ⌃⌥⇧C (#453) — and ⌃⌥⇧⌘ + digit lifts the ladder to Go to Desktop. A toggle still never carries it.

The ergonomics agree. Resize is the one verb a user HOLDS (#1056), and tier 3 is 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.

The base is measured. 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: a 4/5

  • 7/8 layer with 8 on Grow height is 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 says 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 misses them. SystemShortcuts.map 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. 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 (1–4, 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; the criterion is the rule. 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 a reputation-based sentence rots.

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: resize is HELD (#1082), so 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. A keypad digit IS its number-row twin (#1074), 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. A user 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: an existing user who WANTS the new map takes Shortcuts & Gestures ▸ Restore Defaults… (#1096, the one road a later default has into an existing install), 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). A gate on “no init.lua yet” silently punishes 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 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). Padding to nine numbered bsp spaces so that ⌃⌥1–⌃⌥9 have somewhere to go (#270) is wrong: a shortcut never needs a pre-created space — focus_space already ensureSpaces on first press — so the nine exist only to back the digits, and every new user stares at nine identical bsp spaces.

What the seed CONTAINS is 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 — saved under its title and flagged Profile.isStarterSetup — 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 seeded Starter profile only covers its first-run display count, so plugging a second monitor matches no stored set, falls to .none, and would compose a workflow Standard — handing the newcomer a different number of spaces and no ⌃⌥N past the seeded count. The override is scoped 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:) 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 not even the same size as each other. The digit-shortcut half is the additive twin: topUpDigitShortcuts extends the ⌃⌥N rows to the Spaces a growth added, so the shortcuts follow the spaces — which digit each takes is #1797’s entry below. Do not “simplify” either recompose site back to a bare StandardProfiles.standard, nor make apply(composed:) discard its assignment again — each reintroduces #485. (#485)

Nothing KiwiDesk writes may give an action a second chord in a layer; Lua may (#1797). [Rationale] A Shortcuts row is one action in one layer, so a second chord for the same action is drawn nowhere — yet it stays registered, and it blocks recording its key on the row that should own it, naming a Space the user never bound it to. So the digit top-up asks per action, never per free digit: pairing each free digit with whichever Space sits at that position hands a reordered Space a second chord and shifts its neighbours by one. A Space named 1–10 takes its own number rather than its place, because digits are handed out once and then belong to the Space by name (#91) — a drag is an arrangement edit and never moves a chord. Existing extras are migrated away rather than surfaced, for every navigation action: #92’s “surface, never prune” protects a binding that can become valid again, and an extra chord for a live action never does — which is also why an orphan Space verb, #92’s own case, keeps all its chords. The migration keeps the chord on the Space’s own digit, so a Space renamed after the seed can lose the positional chord it learned — accepted, since the digit it keeps is the one its name predicts. It reads each stored list alone, so an extra split between gui.json and a profile’s override survives it; the top-up, which counts every profile’s override against the shared base, never makes one — so a verb one profile binds on its own chord leaves the others without its digit, the lesser harm. The rule binds every writer and every navigation action, since a row draws one chord for focus-left as much as for a Space: an import from Lua and the adoption into Settings keep a Space verb’s own digit, else the first chord, and name each chord they left out — dropping it rather than keeping it as a custom row, which the classifier turns back into a hidden navigation row on the next load. A rename over a deleted Space’s leftover rows keeps one too, touching only the renamed Space’s verbs and naming nothing, since the rename is the act that doubled them. Two rows are never merged: a custom row, drawn as a row of its own, and an orphan Space verb, which #92’s Inactive section draws as one row per binding (#1807). Lua stays uncapped, because layers are modal — activating one deactivates the base — so hjkl beside the arrows can only live in one layer, and two chords that do the same thing cannot conflict. A duplicate chord stays refused everywhere (#33/#34/#35). (#1797)

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). Without a row of its own it is invisible — the per-space catalog rows render only live spaces, and the Advanced drawer shows only .custom — so the user is hard-blocked by a holder they cannot see, and the rejection’s Go to scrolls to a row that does not exist. 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. (#92)

A Space that was only ever live takes its shortcuts with it (#1827, owner ruling 2026-10-09, amending #92 and #1507). #92’s argument is that another arrangement can use the binding again: a chord orphaned under a 4-space profile is valid again under the 8-space one. A temporary Space and a held one have no such arrangement. A temporary Space’s number was taken for a task and is minted again for the next one; a held Space’s number was minted for the hold, past every live number, and is minted again by the next hold. A chord that outlived either would aim at whatever unrelated Space takes the number next — a press that “works” and lands somewhere the user never meant, which is worse than a press that does nothing. So when a temporary Space drops, or a held Space’s hold ends without it going home, every binding naming it leaves the base layers and every profile’s override, through one path at the retile every membership change takes; an open Settings draft takes the same edit. A Space some arrangement still declares keeps its chord — that binding is #92’s, and Inactive shortcuts keeps surfacing it — and so does one that went home or was added to the profile, which is live and declared. A Lua-owned config’s shortcuts are init.lua’s, which nothing rewrites.

No new chords for named Spaces or Spaces past ten. Once the order steps and the history reach every Space, a chord per Space past the tenth digit is a want, not a gap: the per-Space rows on Shortcuts & Gestures already offer one, unbound, for every live Space, and Settings ▸ Spaces points there rather than growing a recorder of its own.

The per-Space rows draw every live Space (#1827). A temporary or held Space’s chords work, so its rows sit in the per-Space families after the profile’s own, wearing the Space’s chip, and Inactive shortcuts judges against every LIVE Space rather than the profile’s list. Listing a working chord under Inactive, captioned “pressing one recreates its Space”, told the user the opposite of what a press does; Inactive keeps only #92’s true orphans.

The shortcuts sheet scrolls, and says so — in words. The sheet 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 cap JumpPanelController.sheetCap derives from the screen (#2246). It may therefore exceed the viewport, and the fold is cued by a line beside the sheet’s buttons drawn only when the measured sheet runs past that cap — JumpShortcutsView.overflows(content:cap:), one home, so the cue cannot disagree with the clamp that causes it.

The platform’s own indicator does not work. .scrollIndicators(.visible), 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 appears 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 is not kept as a hedge.

Why words, and why in the button row under the sheet: 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 sits the sheet’s own button row, carrying the layer label and Edit in Settings…, and under that the panel’s footer. A strip of 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. 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) loses, 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 screen-derived cap, and an install with many app or raw-Lua rows overflows any fixed size. The cue is permanent, not a stopgap. The panel’s width is ruled under ▸ The shortcuts sheet is the key panel’s middle side.

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 — its foregroundStyle sites are .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.

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. Sharing one finish across the two surfaces would unify 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.

Amended: under Tint KiwiHub and plates the panel wears the Fill on .clear glass (#2569, owner rulings 2026-10-24; the setting is kiwishelf.set_tint_hub_and_plates). While KiwiHub, the cue plates (Space, Desktop, layout) and the Space-switch slide plates wear the Fill, they wear KiwiShelf’s Fill on .clear glass, fading top to bottom. One Core answer, TilingSettings.panelsWearFill(glassDrawn:), decides that for all of them — the glass, the inks, the highlight and the rim: the toggle on, a Fill with a colour, and KiwiHub’s Liquid Glass leaf as drawn. The answer is pure; Core hands it the gate’s read, the GUI its live Reduce transparency, so a flip while KiwiHub is open stands the Fill down at once. With KiwiHub’s glass off, under Reduce transparency or below macOS 26 they draw the untinted look, since a Fill-matched ink on the fallback material is a pairing nobody measured. The plates also wear the key-window rim (▸ Liquid Glass). Off is exactly the look this entry ruled: frosted, untinted, following Appearance, with no rim and KiwiHub’s kiwi green. That keeps the system-chrome panel one switch away. The untinted ruling is overturned for these surfaces while the toggle is on, and its three reasons are answered one by one:

  1. A tint darkens. That is Glass.tint(_:), a dimmer, and it is not used. The Fill is drawn on the material as a colour, minted by GlassTint.paint with the bars’ cap, so the dimmer measurement does not apply.
  2. The bars’ argument does not transfer. It now does, on purpose. Under the toggle the panel draws the look’s own inks instead of the vibrant system labels. Resting text takes the look’s Item colour, which the look chose to read on that Fill. Secondary text takes the dimmed item ink the hover peek’s header uses, and tertiary text the idle ink, or the header’s ink under Increase Contrast. This is the bars’ chain that “Kiwi, like the bars” was rejected for. It is accepted because the Fill is now the user’s choice for every KiwiDesk glass, and the look’s inks are the ones measured on it. The system labels stay for the toggle off. The reading floor (▸ Liquid Glass, Reading surfaces fade above a floor) measures these inks at the faint end (GlassPaintTests). KiwiHub’s dividers take the tertiary ink at 0.35 of its alpha, 0.6 under Increase Contrast, never the system separator, which a dark Fill pins to the light rule meant for dark system chrome (#2596). A hairline is not text: it holds 1.15:1 at the faint end over white, 1.5:1 under Increase Contrast, rather than raising the Fill’s floor for itself — no opacity under 1 reaches 1.5 there, since the floor holds the tertiary ink at exactly 1.5 (PanelDividerContrastTests).
  3. A green ground erases the accent. There is no kiwi on the tinted panel. The selected row takes KiwiShelf’s highlight with black or white ink, whichever reads on it (contrastingGlyph), and never the Active item colour, which may equal the highlight. A hovered row takes the hover fill and the Hover item colour (PanelHighlightTests).

.clear, not .regular. .regular is frosted, and it hides the rim. That, not the missing active state, is why KiwiHub and the plates read as “normal plates” beside the bars. .clear here was unreadable over bare glass (owner, 2026-09-07). Over the Fill it is fine (owner, 2026-10-24), because the Fill on top takes most of the see-through away and the reading floor covers the fade’s faint end. The variant paragraph above was measured on macOS 26.5.2. On macOS 27 AppKit and SwiftUI .clear are pixel-identical, and .regular is the frosted one in both, so the variant is a real choice in both toolkits. KiwiHub and the plates take .clear while they wear the Fill and keep .regular otherwise. The boot notice is outside the toggle’s reach: it reads KiwiHub’s leaf for whether it draws glass, never the tint, and stays .regular and untinted (#1715).

A light Fill pins the panel light (▸ Liquid Glass, The light limit is AppKit’s).

A Fill with no colour leaves the panel untinted (owner ruling 2026-10-24). While the Fill has no colour, KiwiHub, the cue plates and the slide plates fall back to their untinted, Appearance-following look, exactly as with the toggle off (panelsWearFill(glassDrawn:)). Settings greys the toggle with that reason. The look’s inks were chosen to read on the Fill. Without one, the panel would put them on plain .clear glass under KiwiHub’s dense text, which is .clear without a Fill, the case this entry found unreadable. The fallback replaces the re-measure of that case that the earlier ruling asked for (PanelGlassTests, SpaceSlideSwitchTests, PanelsWearFillTests).

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 is dragged toward a desaturated grey and the delta near-constant, while glass keeps the backdrop’s chroma, so the delta 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 sheet’s button row 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. A tree outside ChromeScanRoots — the panel’s Shortcuts/ tree was one — is not partly covered but silently exempt, which is how such a surface ships.

The shortcuts sheet never lists the key panel’s own openers. The three bindings that open the panel’s sides — ⌃⌥J, ⌃⌥K (seeded per layer, #602) and ⌃⌥L, kiwihub.show’s — are dropped from the builder’s working set and render in no section: the one deliberate exception to the sheet’s “no bound shortcut is ever invisible” contract. Their home is the side switch, which prints each side’s live chord (#2245): it follows a rebind, and it shows in the empty and unavailable states, which no section does. A row would also be self-referential — you just pressed the chord it teaches, and no macOS surface lists its own trigger as content — and, listed, it would surface in Custom, the section that means “user-authored raw Lua”. Consequence to keep: a fresh layer seeded with only the openers shows Open Settings alone, under Apps. ShortcutsSelfRowTests pins the suppression — un-suppressing it re-leaks the seed into Custom. (#602, PR #638, #2246)

The key panel never takes activation, so there is none to hand back. The one panel ⌃⌥J, ⌃⌥K and ⌃⌥L open is a non-activating panel (#1875): it takes the keyboard while the app the user came from stays active. Activating KiwiDesk to give a borderless panel the keyboard raised its other windows — the Settings window, whenever it was open — and AX re-reported Settings focused on every open; it also left #952’s question, which window the user lands in after a close, for a hand-back to answer. Not activating removes both: a close lands where the user already was, and a click away was always the user’s choice. A close therefore owes nothing, Esc included: with Settings in front the order-out keys Settings again, which is where the user was (owner ruling 2026-10-13). Only a Jump pick of another app’s window arms the ignored-panel grace for KiwiDesk’s own pid, since its order-out would key Settings ahead of the window picked. Because KiwiDesk’s main menu is not the one in the menu bar while the panel is up, the panel hands the File and Edit chords to that menu itself, so ⌘W and the edit chords keep working; it claims its own ⌘ chords first and swallows any other chord a KiwiDesk menu holds, since the user believes the app behind it has the keyboard and a ⌘Q aimed there must not quit KiwiDesk. JumpPanelControllerTests pins the arms and PanelHandBackTests the non-activating show. (#952, #1875)

The shortcuts sheet is the key panel’s middle side (#2246). ⌃⌥J, ⌃⌥K and ⌃⌥L open one panel on its Windows, Shortcuts and Layouts sides, in the order of the keys under the right hand; ⇥ steps forward and ⇧⇥ back, wrapping. Three keyboard surfaces in two shapes were more to learn than one — and a third side earns its place only because the field searches it: a read-only page bolted on beside two searchable sides was refused. The sheet is one titled column, as Spotlight’s and Raycast’s results are: a searchable panel is read top-down, ↑ ↓ and VoiceOver then share one row order, and every label gets the panel’s width in every language, where the old two columns halved it. Open Settings leads Apps, which is KiwiDesk’s own app row, rather than standing alone in a one-row group. The panel holds one fixed width, 720 pt, since a width that changed on ⇥ would move the field and the switch under the eye; only the height follows each side, its top fixed at the field, under one ceiling of 452 pt on every side (#2309, owner rulings 2026-10-15 and 2026-10-16 — raised from 400 so a roomier footer row costs the Shortcuts sheet nothing). A glance panel stays compact, as Spotlight’s fixed height does, and a sheet that grew to the screen’s bottom read as a window rather than a launcher; the Shortcuts sheet scrolls past the ceiling, cued in words, and the Windows list holds nine rows of room, the numbered reach. Fixed points rather than a share of the screen, which would grow the panel on exactly the large screens. The fit to just above the screen’s bottom or a KiwiShelf strip, which the visible frame does not exclude, only lowers it on a short screen. The field shows only the typing: the selection is the green row or tile, shown once, and inline completion — weighed — reads as native only where → or Tab would accept it, which here they never do.

The shortcuts sheet folds what reads as one, and nothing the user changed (#2312, owner ruling 2026-10-16). A digit ladder — Go to Space 1, 2, 3 — and a mirrored set — the four focus arrows, grow and shrink — are one fact said three or four times, and they made a stock setup scroll. So a run of consecutive digits on one modifier set, each on its own digit and none named, reads “Go to Space 1–3 ⌃⌥1–3” from two members up, and a set folds only while every member is bound. Anything the user changed — a rebound key, a named Space, a gap — breaks the run and stands alone, so no bound key is ever hidden. A search opens a fold to the members it names, each with its own chord, so “space 3” still finds Space 3. Settings never folds: each chord there must stay rebindable, and the sheet reads as its summary.

The footer lights one hint: the pick live now (#2246, owner ruling 2026-10-13). With the selection gone from the field, the footer says what a press would do: exactly one hint in full ink — the pick that acts on the selection with the modifiers held now, read off the panel’s own key map rather than a modifier table — and every other hint secondary. On Windows that is ↩ Jump at rest, ⇧↩ Move here under ⇧, and the digits under ⌘ or ⇧⌘; ⇧ already rewrites the digit hint to ”⇧⌘1–9 Quick move here”, so ⌘ joining only lights it — one press changes one thing. Layouts lights ↩ Apply, or its digits under ⌘. Navigation (⇥, ← →) and bridges (⌘, Edit) never light, the Shortcuts side lights nothing since ↩ runs nothing there, and an empty list lights nothing. Ink alone carries it, with no tint or weight, and it swaps without motion. The way here follows the chord’s reach: ⇧ alone moves the selected row, so only it shows its “Space n → Space m”; under ⇧⌘ every numbered row is a target through its digit, so each shows its own.

The layout picker is a side of the ⌃⌥J panel, not a menu (#2229). #762 made the picker a native menu because a menu brings arrows, type-select, Return, Escape and VoiceOver for free. That argument holds for a list of names, and it is why a Space chip’s Layout menu stays a menu. The picker became a panel because a panel can show each layout’s picture, drawn by the same schematics Settings uses, and a picture answers “which one is Track?” faster than a name; a panel of names alone would pay the same cost for nothing. The cost is real, and it is paid in the panel: the arrows, the typed filter, Return, Escape and the ⌘ digits are its own keys, each tile is a VoiceOver button that says which layout is current, and the current layout is marked with a ✓ glyph so it is never told apart by colour alone.

The selection is kiwi green because the panel is KiwiDesk’s own window (#1293 tints every one), and a native menu keeps the system accent because re-tinting a menu fights the platform. So green marks what KiwiDesk draws and the user’s accent marks what macOS draws, and the line between them is the window, not the feature.

Under Tint KiwiHub and plates the selection takes KiwiShelf’s highlight instead (#2569). The line holds: the look’s colours are still KiwiDesk’s own, not macOS’s accent.

One panel holds the windows and the layouts because both answer “take me somewhere” from the keyboard, and one field on top keeps typing the quick way in, as Spotlight’s categories do; the shortcuts joined as a third side under ▸ The shortcuts sheet is the key panel’s middle side. ⇥ steps between the sides and clears the field, since the same letters mean a window in one and a layout in another. The other scope’s own shortcut switches too, so ⌃⌥L inside Jump lands on Layouts. In the Layouts scope ← and → move the tiles even while the field has focus: a one-word filter has almost nothing to edit with a caret, and the tiles are one row. The buttons are not Tab stops, because Tab is the scope switch and the field must keep the keys; each shows its chord instead (⌘S Keep, ⌘, Layout Defaults…), the way a menu item does. One row of seven tiles was chosen over a 3/3/1 grid: the row keeps the panel at Jump’s width, and ↓ from an outer tile in a grid has no tile below it.

The pointer lifts and the keys select (▸ In KiwiHub the pointer lifts and the keys select). The ✓ never moves with the selection: the pill marks where ↩ goes, the ✓ what the Space shows. One verb opens every side, kiwihub.show, and the CLI reaches it (▸ The key panel is KiwiHub). JumpScopeModelTests, ViewPickListTests and JumpKeyTests hold the keys. (#2229)

In KiwiHub the pointer lifts and the keys select (#2365, owner ruling 2026-10-17). The item under the pointer takes a neutral grey lift; the green selection moves only by a key or a click. A menu’s highlight follows the mouse because a menu is driven by the pointer alone. KiwiHub is driven by the keyboard, and more than a row reads its selection: the footer’s Settings button names the selected line, the lit hint says what ↩ does, the ⌘ digits count on the selected line and ⇧ shows the selected row’s way here. A selection that followed the pointer let a hand resting on the trackpad retitle the button and re-aim ⌘3 while the user typed.

A click still selects the item before it does what ↩ does, so the footer never describes something other than what was acted on — Save as New Profile… stays open to be named — and an item the query dims, which ↩ cannot reach, takes no click either. A key that moves the selection clears the lift until the pointer really moves again, so ↑, ↓, ↩ and the digits always start from the green; a row that slides under a still pointer — the panel opening there, a re-rank, a scroll — lifts nothing. The lift is the hover ink Settings already uses and never the accent, a border or an ink change, and the selected item draws none, since a durable marking and a pointer state never share a property (#1173). VoiceOver follows the selection only. JumpHoverTests holds the rule.

Under Tint KiwiHub and plates (#2569) the panel wears the bars’ look (▸ the shortcuts panel entry), so its marks take the look’s colours. The selection takes KiwiShelf’s highlight, and the lift takes the hover fill with the Hover item colour, so a hover means one thing on the bars, the peek and KiwiHub alike. The lift then changes the ink, which this entry otherwise refuses. That is the look’s own hover pair, and it keeps the rule’s point: the selection and the lift still draw on different fills.

[Trade-off] A searchable dropdown’s pointer and keys share one cursor (owner ruling 2026-10-17). The font family picker, the app picker and the header search draw one highlight, solid accent, moved by ↑/↓ and by the pointer alike, as a native menu’s is. KiwiHub keeps the two apart because its footer, its hints and its ⌘ shortcuts read the selection. In these lists nothing but Return reads the cursor, so a pointer that moves it changes only what Return picks, and a grey lift beside it would be a second, near-identical state to tell apart. The cost of a shared cursor is a row taking it under a resting hand, so the pointer moves it only when its location changed since a row or a key last heard it, the rule #2229 gave KiwiHub: the list opening under the pointer, a re-filter or a scroll takes nothing, and every key, query edit and Return re-records where the pointer rests. Unlike a menu, leaving the list keeps the cursor, so Return always has a target, and the pointer never scrolls the list. The stored family’s ✓ stays a mark of its own, inked to read on the accent, and .isSelected stays on it (#1173). DropdownCursorTests holds the rule and DropdownCursorWiringTests the three lists’ routing.

The ⌃⌥L side is View: one chord reaches every daily switch (#2309, owner ruling 2026-10-15). #2229 and #2246 admitted a side for its search alone. The layout, the profile and KiwiVision are the three things a user changes through the day, and a status menu can reach them only with the pointer, so the third side widened to hold all three and took the name View. It stayed one side rather than becoming a fourth: the side switch shares a row with the search field in a panel of fixed width, and the three answer one question, how the desk looks now.

Each group is one line that never wraps, so ↑ and ↓ always mean “the next line” and the panel’s height does not depend on a name’s length; a longer line scrolls sideways, keeps the selection in view and fades at each edge it cuts. ↑ and ↓ keep the position along the line, clamped to a shorter one, the way a grid of icons in Finder or the emoji picker moves. The ⌘ digits count on the line the selection is on, among the matches while a search is typed, never by what scrolled into view. Per-line modifiers were weighed and refused: ⌃⌥ is still held from the summon, ⌥ with a digit types a character, and ⌃ with a digit is macOS’s own Desktop switching. The panel opens on the Layout line, so ⌃⌥L ⌘3 still applies the third layout. ⇧ with ↩ or a digit does nothing here, since there is nothing to move.

The Profile line always shows, ending in Save as New Profile… (▸ A create chip is a line’s last item), and a profile that cannot load stays listed, greyed, with its reason, rather than vanishing. With no Space under the pointer the Layout line greys instead of the side being stepped past, because the other two lines need no Space. ViewPickListTests, ViewPickItemsTests and JumpScopeModelTests hold it.

KiwiHub’s KiwiVision line has no Off chip: the item already on turns KiwiVision off (#2103 rulings 55–57, owner 2026-10-16). Every other chip on the line turns something on, and KiwiVision’s base settings always exist as Default, so an Off chip was one more place to aim for a state that already has a chord. Control Center’s Focus menu is the model: clicking the Focus already on turns it off, and a lens’s own chord already behaves that way. The selection rests on the current chip, so ↩ there turns KiwiVision off, and the panel says so before the key is pressed: the footer, the chip’s VoiceOver hint and, with nothing on, the line’s spoken title, since then no chip says “current”. Only this line toggles. A profile or a layout has no off, so its current item re-applies.

The base settings are named Default rather than “Focused window”. That was a mode’s name, and wrong for both meanings: “The current app’s other windows” keeps more than one window clear, and the base applies whether or not a lens is on. “Default” is already KiwiDesk’s word for the base an override sits on, as Layout Defaults are for a Space’s overrides.

A create chip is a line’s last item and no pick (#2359). “New Lens…” ends the KiwiVision line always, not only while no lens exists, and “Save as New Profile…” ends the Profile line, so a line keeps one shape. Saving a profile from KiwiHub names it in the search field rather than in a dialog, since the panel already holds the keyboard, and saves the live arrangement — never a Settings draft — under a free name: a panel driven by single keys must never replace a saved profile without a word, so a taken name saves beside it. The save claims the connected screen setup, as every save does, and the caption says the new profile then holds these screens. Placed first, a create chip would shift every ⌘ digit and put ↩ one keystroke from a create. The digits skip it, ↓ never lands on it while the line holds anything else, and the filter finds it by name. A Lua-owned setup draws New Lens… greyed with its reason rather than hiding it. ViewPickVisionLineTests, ViewPickItemsTests and JumpProfileNamingTests hold it.

The key panel is KiwiHub, and one verb opens its sides (#2265). Since the three panels became one, a user met three names and no parent: the Shortcuts page filed the window switcher under Focus, the layout picker under Layout and ⌃⌥K with the app chrome. A product name, like KiwiShelf’s for the bars, lets copy talk about the panel as one thing; Jump stays its verb and never its name. The sides keep their own names, which their rows and VoiceOver already use, and the panel’s container speaks the product name around them, so a side announces “KiwiHub, window switcher”. The Shortcuts page groups the three rows under one KiwiHub card, J K L in key order, because the panel is the topic and the trio reads as one. Open applications ▸ KiwiDesk keeps Open Settings under its subheading, one row and all: there the heading says whose window the row opens, among rows that open other apps, which a lone row still needs.

The three show_* verbs became kiwihub.show(side), the side a typed argument read off KiwiHubSide. Only show_shortcuts had shipped, and Settings writes its exact call into stored bindings, so a one-shot migration renames that call — and the two 3.0.0 development spellings, which development builds wrote into real files — while hand-written Lua is refused with a Config Issue naming the new verb. The verb is dispatched rather than Lua-only, reversing what #2229 ruled for the picker: it answers a refusal for a side it does not know, which is something a CLI caller can read, and a launcher (Raycast, Shortcuts, skhd) then opens KiwiHub without a keybinding. It moves no window, so it is no focused command for the #292 preflight.

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)

A menu-bar reveal’s activation is returned, once the bar hides (#1532, #2224). With the menu bar set to auto-hide, macOS 27 answers the pointer reaching the top edge by activating the last regular app, so there is a bar to reveal — KiwiDesk is an accessory app and owns none (the #329 row in accepted limitations) — and a clickless focus report for that app’s window lands ~190 ms later, ahead of the bar itself (device measurement 2026-09-21, bsp and scrolling alike). The user asked to see the menu bar, not to change windows. The #952 boundary binds as it does for #958: the activation is a genuine OS focus event, so Core keeps state focus on the own window. It does not re-assert while the bar shows: on macOS 27.0.1 a return raised at once was taken back by macOS every ~1.3 s for as long as the pointer stayed, and the Settings window flickered between active and inactive (device log, 2026-10-13, #2224; the 2026-09-21 reading that the re-assert HOLDS no longer does). So the report is taken and the return OWED, and paid once — when the pointer leaves the strip with none of the revealed app’s menus open, or KiwiDesk’s own quick menu closes, whose opening brings the same activation wherever the pointer is. Not dropping the return entirely: after a peek at the bar the user keeps typing, and the keystrokes would land in the other app. Not promoting KiwiDesk to a regular app either, which would own a bar but is the activation-policy switching ruled out above (owner and ui-designer, 2026-10-13). The payment is the STAMPED raise (raiseWindow) rather than #958’s direct one, because in a scrolling Space an unstamped raise’s report is exactly the clickless focus the placement distrust bounces (#1414’s class) — the stamp makes it our own echo (#1281’s point). Not the focus command either: its displacement note would put the foreign window in the placement ledger, which in an active scrolling Space bounces every later report from that app. Decided at the report from readable facts rather than armed ahead, since nothing precedes the bar’s reveal: a report from another app while the active Space’s focused window is one of our own pid — a sticky own window rendering as a traveler elsewhere is not — and the pointer in the reveal strip or the quick menu open. The quick menu’s half has ONE more moment, because its report comes first: the status item is pressed, AppKit builds the menu, and the activation’s report lands inside that build — 52 ms ahead of menuWillOpen on device (2026-10-13), so a flag set at the open declined every quick-menu report, and the press itself reaches no local event monitor (measured the same day). So the arm keeps the one report it declined only for the menu being shut, and the open looks back: within a one-second grace, with no other window reported, no commanded focus and no click on that window since, and the anchor present, the open owes the return the report would have — state focus back on the own window, the Space follow that report scheduled cancelled, the payment waiting on the close as it does. The residue is any clickless OS-made switch out of Settings inside that second — a ⌘-Tab, a Dock click, a Spotlight open — followed by a click on the item, which is paid over. A click chooses nothing (owner, 2026-10-13): the bar a reveal shows is macOS’s, since KiwiDesk owns none, so opening that app’s menus or picking one of their rows is not the user picking that app — only a window’s own focus report is. So the return is stood down by a report for any other window, honored (a ⌘-Tab, a click into another window, a window a menu row opens), and otherwise only by the user or the arrangement moving on: a commanded focus since, an own window keyed since (a quick-menu row fronting a panel), the anchor closed, a re-assert that would switch Desktops (#1345) — asked again at the payment, which can come seconds later — and 30 s from the report. A click that reached the reported window still refuses the arm, being a window choice. The quick-menu half needs no macOS 27 or auto-hide gate: no foreign activation while KiwiDesk’s own menu tracks is the user’s. The accepted trade: while the bar shows, the Settings window looks inactive and the other app’s menus are in the bar. (MenuBarRevealReturnTests, MenuBarRevealPaymentTests, MenuBarRevealSeamTests)

Open or Focus is focus_or_spawn, not pull_or_spawn (#1511). The verb has never pulled a window: from its first version it went to the app’s window where it was, and every extension since — the ring, the un-park, the reach onto another Desktop — goes there too. “Pull” misled readers into expecting the window to come to the current Space, and it would have meant the opposite of itself beside a verb that does gather windows. “Focus” is the vocabulary’s word for going to something (focus, focus_space), and “spawn” keeps the pair with spawn_new.

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 for the walk 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.

With no window at all — none up, none minimized, none on record on another Desktop — there is nothing to restore and activate() asks for no window, so the press would again do nothing: the shortcut reopens the app instead, sending the reopen a Dock click sends, and the app opens a window by its own reopen handling. The reopen is kept to that case: with a minimized window present, the restore above already answers, from an order the user made. (#1840)

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)

Open or Focus lands on the window you used last (#1840). [Rationale] A first press — no window of the app focused yet — focuses the app’s most recently focused tracked window, switching to its Space; with no recency yet (KiwiDesk just started) the app’s own focused window; else the first window in ring order. The repeat-press ring above is unchanged. This is not the MRU #637 rejects, for the reason close-return focus is not (below): the objection is to a repeating gesture whose third target rests on history the user cannot see. A first press is one step to the window the user last left, and from there the walk is the fixed ring. Ring order for the first press instead lands on whichever window the Space order puts first — often one the user has not touched in hours.

Recency is KiwiDesk’s own, stamped at an honored focus, rather than the app’s focused window alone: KiwiDesk’s own raises — a z-order restore, a lazy app’s re-report of a sibling (#496) — move the app’s focused window without the user choosing it, and the common press then needs no synchronous AX read. The app’s answer is the fallback only while nothing is stamped. The stamp is the window’s, not its Space’s or Desktop’s: a Desktop departure, a minimize and a hide keep it, and only a close or the app’s exit ends it, so a window that comes back is still the one the user last used.

The press focuses by command — a window in another Space is reached by a Space switch, the way a follow move is — never by activate() and a wait for the app’s focus report. For a window a Space switch has just parked, that report is the clickless focus #1161’s placement distrust bounces, and each refusal renews the ledger, so the press would do nothing however often it is repeated. A target on a Desktop nobody shows (a native-fullscreen window) is left to the ordinary path, since raising it is a Desktop switch (#1345). Fixed behavior, no setting. The walk after it stays the #637 ring: history picks where the first press lands, never the order of the presses that follow.

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.

The page is “Shortcuts & Gestures”, and what the mouse does is explained at its top (#1726, owner and ui-designer 2026-09-27/28). A new user never discovers a mouse control from a window of keyboard rows, and the scroll gestures that follow (#1656, #1519) need a home for their modifiers, which are input bindings like every chord here. So the page names both inputs, the way “Gaps & Borders” pairs two nouns; Controls was refused because every row in Settings is a control, and gesture is Apple’s own word for trackpad and mouse input.

The Mouse & trackpad card sits above the layer header, and is collapsed on every visit. Everything under “Editing the X layer” reads as belonging to that layer; the card’s settings do not, so it cannot sit there, and the layers card leads only what is layer-scoped. It opens shut every time because a card that opens on a first visit needs a stored “seen” flag and then changes shape on the second; search opens it on a hit, and its summary does the telling while it is shut.

Since the layer’s name moved onto the pinned jump bar (#1520), the separation is carried by the rule after the Mouse & trackpad chip and by the Layers card, which leads everything layer-scoped: the card above it belongs to no layer.

Its entries are grouped by where the hand is — on your windows, on the KiwiShelf, anywhere holding a modifier — never by gesture type, which is grouping by widget. That grouping is also what keeps a plain scroll over the shelf apart from a modifier scroll anywhere.

An entry is one drawn picture and one sentence, and the picture animates on hover. The one-frame rule (▸ Navigation & saving, A layout gets one frame) does not bind here, and the difference is the subject: a layout schematic teaches a REST state and sits in a strip built for comparison, while an entry here teaches a GESTURE, alone on its row, which no rest state denotes. It is still not a clip. A bundled video cannot follow the user’s palette or appearance, cannot be localized, and goes stale on the next shelf redesign; the drawing is built from the same shapes as the rest of the window and rests on its key frame, which is all Reduce Motion ever shows. It plays while pointed at.

When the user’s own click opens the card, its first picture also plays once and rests (owner ruling 2026-09-29), and the difference from the rest of this page is the reader. The rule’s other exception, a layout thumbnail that is read rather than compared (▸ Navigation & saving), plays once as it appears because the tour and the preset preview are opened in order to be read. Opening this card is the same kind of act — a request to read what is inside — so one picture playing is the content arriving, and it tells the user the pictures move. It stays one picture: the card is still a reference consulted in the middle of something else, and a column of gestures playing in turn would be motion no one came for. So it plays at most once per visit, only on the click — a search hit, Go to or a diff jump opens the card to reach one row, whose wash is the motion that answers them — and never under Reduce Motion.

Shut, the card shows every entry’s picture (owner ruling 2026-10-17). A collapsible section’s shut state is its summary row, and here that was one small ink3 sentence between two cards with real controls — the one card on the page users overlooked. So the shut card also draws a strip of every entry’s picture at rest, in card order with the groups set apart, scrolling sideways and fading at an end where it cuts a picture, the KiwiShelf’s overflow arithmetic (ShelfOverflow.fades) rather than a copy. A picture stays still, shows its entry’s own sentence as a tooltip after the window’s 250 ms delay, marks itself as a link under the pointer with an accent edge and the pointing hand (#2537) — the accent, not the neutral hover lift, because the picture is otherwise read as decoration and its plate carries no marking the edge could overwrite — and a click opens the card at its entry and plays that one — the same “a click asked to read it” argument as the first picture’s, aimed. The strip is a pointer shortcut: hidden from VoiceOver, never a focus stop, since the disclosure above it opens the card where every entry is reachable. It breaks the “summary is words only” convention for this one card, which is a section — a peer of the page — rather than a drawer: a picture is the content arriving, where a drawer’s summary states a value. Every entry is drawn rather than one per group because each picture is its entry’s own way in; the cost is weight, so a strip that reads as a solid band in light mode is the signal to cut it back to one picture per group. It first played a picture while hovered, and that cost the page its scroll (owner ruling 2026-10-18): each picture the page carried under the resting pointer rebuilt and started a loop mid-scroll, so a wheel or swipe begun over the strip stuttered. The tooltip already names the gesture and the click plays it, so the hover loop taught nothing they do not.

An entry lands with its feature, and greys where its surface is off. Describing #1528’s clicks before they ship teaches what the app does not do — the channel-not-yet-existing case, removed rather than dimmed. An entry for a bar that is switched off stays, dimmed, with one sentence pointing where it turns on; an entry’s own checkbox is never greyed for being off, since a greyed control says “you cannot change this”.

A mouse or trackpad setting lives beside the gesture it tunes, whatever page that is, and profile scope does not stop it. The mouse resize action and the pointer-follow are profile values, and this page shows the header’s profile chip like every page a profile owns, so they edit the profile the chip names. The pointer-follow is its own entry rather than a row in the Focus card: it fires on every focus change KiwiDesk sees — a Space switch, a closed window, ⌘-Tab — not only on focus shortcuts, and that card sits under the layer header.

⌃⌥ + scroll moves focus a window at a time; it never pans the row freely (#1656, owner ruling 2026-09-29). A Scrolling Space’s view is a function of its focus — the row pans to the focused window, and every anchor (follow, center, start, end) is stated against it — so a gesture that moved the view by itself would be a second writer of the viewport, owing a rule for handing it back when the fingers lift and a border that answers to neither. A device build of the fluid pan measured the cost: moving every window on every scroll event left the focus border trailing the row it frames, made a glowing border’s surface redraw so the background seemed to move with it, and gave a mouse notch about 10 pt of travel, too little to read as anything. Stepping goes through the arrow keys’ own focus step instead, so the animation, the border, the deferred raise and the focus wrap are the keyboard’s and cannot drift from them; a scroll gesture that grows a movement path of its own re-opens all three measurements. It takes navigate but not execute, whose foreground preflight (#292) refuses a focused command while the frontmost app is not the anchor’s: the gesture acts on the Space under the pointer, so its first step onto another screen would always be refused. No step warps the pointer, on any layout (owner ruling 2026-09-29).

A held chord always does something, on every layout (#1656, owner and ui-designer ruling 2026-09-29). A Scrolling row pans, a Monocle stack steps, and every other layout — BSP, Stack, Grid, Track, Floating — steps focus to the next or previous window in the Space’s own order, floating windows included, wrapping at the ends. The scroll is taken on every Space while the chord is held, rather than passed through where the layout has no row, because a chord has one owner: passed through, the same hand would do two things depending on where the pointer rests. With two screens a swipe meant for the Scrolling Space on one scrolls a page when the pointer drifts onto the other, and a page scrolling under a held window-manager chord reads as KiwiDesk broken, not as a layout without the feature. Taking the scroll and doing nothing was the other option, and it fails the rule that a press which does nothing always says why (▸ Layout and resize behavior, A press writes forward, never across the store) — while here there is nothing to refuse, since the flat window array gives every Space a well-defined next and previous window whatever its geometry. Where a step still lands on nothing — a Scrolling or Monocle row’s end with the focus wrap off, or a Space holding one window — the ring takes the arrow keys’ own dead-end bump toward the step (#436; owner ruling 2026-09-29), once per event and never a pill: the gesture should answer a wall the way the keys do, and a worded cue would repeat per notch and per swipe where a rubber-band simply restarts. A pill is for a refusal whose reason the user cannot see; a row end is on the screen.

One swipe moves one window by default, and long swipes are a tick box. Counting by distance — a window per ~60 pt of finger travel — makes a single window hard to hit (measured on a device build), and macOS’s own Scrolling speed scales the travel KiwiDesk reads by about ten times between its slowest and fastest settings (#1637’s measurement), so no fixed distance means “one window” on every Mac. One per swipe is a step a user can aim. Long swipes move more windows keeps the first window at the swipe’s start, so exactly one is still easy, and adds one per Travel per window after it, on every layout: the meter counts the hand, and the layout only decides where each window’s step lands. That distance is a Settings field rather than Lua-only because the Scrolling speed that scales it is system-wide: a user who likes macOS’s speed everywhere else still needs this gesture’s sensitivity apart from it. The glide after a lift never counts, on either setting — it is macOS extrapolating a flick, not the hand, and a fast flick’s glide runs far past any swipe, so counting it would race down the row. A wheel notch is one window either way, since a notch is already a discrete act — but a fast roll or a free-spinning wheel is one window for the whole burst: a notch too close behind the previous one in the same direction moves nothing, so notches clicked one at a time each count and a spin cannot race down the row (the Space step’s paragraph below argues the number).

The settings are stored like the shortcuts: a base in gui.json, a sparse override per profile, and the Applies to checklist on each row, with that checklist’s semantics (▸ Navigation & saving, Each App Rules row carries an “Applies to” checklist). A chord is an input binding like every key chord on the page, so a user who gives two profiles different keys expects the gesture’s chord to follow the same model. The one difference is that the base holds every field: a profile never leaves a value out, so there is no “Not here”, and turning the gesture off in one profile is an empty chord stored as that profile’s override.

Natural scrolling is KiwiDesk’s own, per input, and on by default. The gesture moves windows, not the content under the pointer, so the direction a user chose for documents in macOS need not be the one they want here — and a notched wheel rolled toward you and fingers dragged across a surface are different hands, so a single toggle would force one input’s answer on the other. The split follows the event rather than the device: a scroll that reports finger phases, a Magic Mouse’s included, takes the trackpad’s value. The rows quote Apple’s own Natural scrolling and Content tracks finger movement, so the concept is the one the user already knows from System Settings, and the default matches macOS’s own.

The chord takes two or more modifiers, and that is why there is no Zoom warning. Measured on macOS 27 with Zoom’s “Use scroll gesture with modifier keys to zoom” set to ⌃ and no KiwiDesk tap running: ⌃⌥ + scroll did not zoom, and only ⌃ + scroll did — Zoom fires on its modifier held alone. The recorder refuses a single modifier (⌃ + scroll is Zoom’s default, and ⌘ or ⌥ + scroll mean something in many apps), so no chord the recorder or a verb accepts can meet Zoom — and a hand-edited file cannot either, since resolving the settings turns a single-modifier chord off — and a warning reading Zoom’s preference would guard a case that cannot arise. The refusal is what makes the warning unnecessary, not the ⌃⌥ default: allowing a one-modifier chord anywhere re-opens it.

The two scroll gestures take different chords, and ⌃⌥⌘ is the Space step’s (#1519, owner ruling 2026-09-29). Both read either scroll axis — a Scrolling row can run vertically, and ⇧ is allowed on either — so no direction can tell a pan from a Space step, and one chord would reach only whichever gesture comes first in the tap’s order. The recorder and set_pan refuse the Space step’s chord for that reason, and set_space_step refuses the pan’s. Where a file still gives both the same chord, the pan keeps it and the Space step is off: a file sharing one chord was written before the Space step existed, so it was written for the pan. The refusal under either recorder offers Go to and nothing else — the other gesture’s row is where the user decides which of the two gives the keys up.

⌃⌥⌘ + scroll steps through the Space order of the screen under the pointer, one Space per swipe or notch (#1519, owner and ui-designer rulings 2026-09-28/29). The order is the Space Bar’s, empty Spaces included, and never the visit history #1655 proposes: an order the user can see on the bar is one they can aim at, and hide_empty is a display setting, which must not change what a gesture reaches — the Space landed on shows, because the bar always draws the current one. It stops at the first and last Space, as macOS’s own Desktop swipe does — the shown Space’s ring bumps there, as a row end does, and an empty Space, having no ring, stays still — and a wrap would only make an overshoot worse. There is no long-swipe option: a Space switch redraws the whole screen, so counting distance would repaint it several times for one hand.

Amended by #2592 (owner ruling 2026-10-25): the step wraps unless Wrap focus is Off. The gesture and the next / previous Space keys take one step, so they answer an end alike, and a second switch for one question would be the three per-layout answers #2493 retired. The meter still bounds a spin to one step; a deliberate step too many now lands on the far end, the accepted cost, and Off is the wall for whoever wants one. Under This screen and All screens the step goes round the screen’s own Space order and never onto another screen — the order the bar shows is the one being stepped, and another screen’s Spaces are reached by focusing that screen. Off keeps the stop and its bump, never a pill. The slide plays in the key’s direction, so last → first moves forward with the hand rather than rewinding the strip.

A wheel steps once per notch, and a spinning wheel once per spin, on both scroll gestures. A free-spinning wheel reports a burst of notches after a single flick, and one per notch would carry it to the last Space. So a notch closer than 120 ms to the previous one in the same direction steps nothing, and the wheel re-arms after that quiet or when it turns the other way: notches clicked one at a time each step, while a fast roll or a spin steps once, which is what a trackpad’s one-per-swipe already does. The rule reads the hand’s spacing on the tap’s own clock, never the main actor’s, where a busy switch would squash deliberate notches into a burst. The number is provisional until a device logs notch intervals, and it lives on ScrollStepMeter.wheelQuiet.

The page is mapped by pinned jump chips, never split into tabs (#1520, owner ruling 2026-10-01). A group well below the fold of a long list is undiscoverable without a map — the per-app shortcut, which answers “switch to the Space an app has windows in”, is the costly case. Tabs are easier to click but hide every group but one, so a conflict banner or a search hit lands on a page whose siblings are out of sight, and search would have to switch tabs as it opens drawers (#1250). Chips keep the page one page. Pinned, they also answer what tabs avoid — with chips at the top of a long list, reaching the next group would mean scrolling back up first. Tabs stay the update window’s control, which shows separate views one at a time; chips jump within one view. Moving Open applications up was refused too: it trades one group’s discoverability for another’s, and the group’s rank is fine once a map exists.

Layers gets no chip. It picks which layer every group below edits — scope, not a destination — and it sits above the groups it scopes, so a chip for it would point at its own neighbour. The bar carries the edited layer’s name instead, in words, at the trailing end of the caption line over the chips, and that line is the first thing a narrowing window drops since it is header chrome. Mouse & trackpad gets the first chip, set off by a rule because everything after it is scoped to one layer and it is not; its click opens the card as well, which stays fully collapsed otherwise — a first-item-open state would be a third collapsible kind and split its search children (#277). The chip pinned above it is what keeps the shut card from being overlooked at the top of the page.

Fewer groups, chosen by what a header tells apart. The General drawer — two rows, Show shortcuts panel and Open Settings — folds into Open applications under a “KiwiDesk” subheading: both open something by key, and a two-row drawer was a disclosure guarding almost nothing. (Show shortcuts panel has since moved to the KiwiHub group, #2265.) Focus and Move windows stay apart although they share keys: merged they run 17–23 rows, and the header is what tells “where focus goes” from “where a window goes” on the same arrows. The section keeps its name: Controls stays refused, every row in Settings being a control.

One card for what shows and hides. KiwiHub, KiwiShelf and KiwiVision each arrived with a card of their own (#2265, #761, #2312), so the page grew a card and a chip per product, and three chips named a product rather than a job. The three are one job — each key puts something on screen or takes it away and moves no window — so they share one card named by that job, Show & hide, each product a sub-heading. KiwiHub leads, the panel being what a new user reaches for first; KiwiShelf follows; KiwiVision goes last because it grows a row per lens. The card leads the page, ahead of Focus, for the same reason KiwiHub leads the card. Focus, Move windows and Layout stay apart, by the header argument above. KiwiDesk was refused as the card’s name: every row on the page is KiwiDesk’s, and it is already the sub-heading naming whose window Open Settings opens. This amends #2265’s KiwiHub card, which becomes the card’s first sub-group, J K L kept. The ⌃⌥K shortcuts panel keeps its own bands, KiwiVision among them: it reads a layer as a reference rather than an editor, and regrouping it is a ruling of its own.

A binding names its group by token; a name of its own is its own group (#2544). A binding’s group matches a built-in group by its Lua token (focus, move_windows, …), never its English title, so the group still reads in the user’s language; any other name makes a group of its own, shown as written and never translated, after the built-in groups and before Custom. An unknown name is never an error: a typo makes a visible group, which says more than a refusal would. The fields live on the stored binding, so a binding imported into gui.json or written there by hand carries them as one init.lua declares does. </> marks your own Lua everywhere — Custom rows, a name-less handler, the Lua editor’s tab — and a symbol that is no SF Symbol falls back to it with a Config Issue; braces read as a code block, </> as code you wrote. curlybraces stays in the icon picker as an icon of its own.

The marked chip is the group under the bar. A group is current once its header has reached the bar and while its card is still under it, so scrolling past Mouse & trackpad into the layer chrome marks nothing — no chip names what is there. Unscrolled, the first group is marked, whatever banner sits above it. A clicked chip stays marked until the user scrolls, since a group near the end may never bring its header to the bar; a scroll the user takes to the end marks the last group for the same reason. Marking is a fill on a layer of its own and hover a neutral fill beneath it (#1173): a pointer resting on the marked chip must not read as unmarking it.

A chip must read as a button before it is pointed at (#1520 amendment 5, owner ruling 2026-10-07). A hairline capsule is the passive chip’s vocabulary — a badge, a tag — and on the chip’s own rest fill the container hairline all but vanishes in dark, so the row read as labels. The chip therefore draws an edge of its own, chipEdge, which must separate from the rest fill it rims by more than the container hairline separates from the page, in either appearance (SettingsThemeContrastTests ▸ chipEdgeOutreadsHairline), and which no state moves: the edge says “button”, the fills say what the pointer is doing. A press deepens the pointer’s layer (chipPressed), which still sits beneath the marking, so holding down the marked chip cannot unmark it either. No shadow: buttons take none (ui-patterns ▸ Buttons take a native style), the thumb’s shadow being the settings’ sign for “movable”, and the edge already carries what a shadow would.

The row leads (#1520 amendment 6). Every wrapped line starts at the leading inset, where the pane’s headers start and where the eye returns after each line; a centred wrapped row reads as a tag cloud, and centred segments mean tabs on macOS.

The row says what it is. A “Jump to” caption sits on a line of its own above the chips: the row’s VoiceOver name, drawn, and hidden from VoiceOver so it is not spoken twice. The edited layer’s readout moves onto that line, at its trailing end, so the chips below are only chips; where caption and readout do not fit side by side the readout wraps under the caption, never beside the chips. The caption takes the readout’s face — .callout in ink2, never ink3, which reads as dimmed — and the whole line leaves at the chrome step, being header chrome, the bare chips remaining. No colon: the form’s labels take none, and a colon’s spacing is per-locale. The caption takes a line of its own rather than leading the chips inline, though that makes the pinned bar a line taller: the top of a pane is easily passed over, so what sits there only informs — a quiet caption, never a container header. A card-header tier above the row was refused, since on a pinned bar it reads as a frozen section title, and its semibold would equal the marked chip’s “you are here”. An instruction such as “Click to jump there” was refused as well: it names one input of several. The caption does not replace the edge — it labels the row once, where the edge tells each chip from a passive capsule on every look, and without the edge a dark jump chip is chipSurface() again.

Layouts to cycle is a button that opens a popover (#2673, owner 2026-10-27). [Rationale] The row’s value, “BSP › Scrolling › Floating”, is a bordered button in the recorder column; its popover lists all seven layouts, the cycle’s ticked and in order with a drag handle, the rest unticked below in the Layout menu’s order. A tick appends a layout and an untick takes it out; at two, the ticked boxes grey and a sentence under them says why. This reverses #453’s inline list (2026-10-11), which unfolded a list with a + menu and − buttons inside the Layout card and pushed every row below it down: the card is a column of one-line settings, and a list growing out of one of them read as a section of its own. Seven boxes also make the choice visible at once, where + hid the layouts not yet chosen behind a menu. Move Up / Move Down stay on each ticked box’s row menu for the keyboard.

[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). 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.

Floating gets both marks too — the floating mark entry below argues why floating is not self-evident — and its switch is unconditional for the same reason as sticky’s. So is the Floating tint: it colours the on-window floating mark as well as the Space Bar’s badge, so with the bar off it still has something to paint. A tint that painted ONLY a bar surface would earn the gate; neither mark colour does.

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 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; 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 (#438) wherever a layout piles part of a Space.

Since every overflow scrolls and only a whole Space piles (#2542), no pile has a sticky exemption to refuse, and the pill is retired.

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 in a minimal floating namespace. 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 is infinity (“always / everywhere,” and a single stroke that stays crisp at the 7–9 pt badge size where 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)

floating.color tints the floating badge and the on-window floating mark alike, as sticky.color does sticky’s pair.

On Liquid Glass the mark’s disc goes (#1621). It existed because .hudWindow carries no colour; tinted glass carries the colour itself, through GlassTint.apply, fading downward. The glyph then takes .labelColor — under the dark variant GlassTint pins from a dark colour (#1308), under the app’s appearance otherwise — never contrastingGlyph of the hex: the glass shows the colour at a capped alpha over whatever lies behind it, so a black/white pick made from the hex judges a colour that is never on screen. The two surfaces still read as one mark through the one sticky.color: a disc on the Space Bar badge, the glass’s tint on the mark. With the finish off, or Reduce transparency on, the disc returns.

The pocket mark takes the other branch on purpose (#2671): its colour is drawn opaque IN the glyph, filling the pocket, and its plate takes no tint, so the colour on screen is the hex itself and the dashed stitch takes contrastingGlyph of it, as the flat disc does. Moving the stitch to .labelColor would draw a black or white line on a pocket the label ink judged against nothing. The pocket’s outline is the colour itself and the kiwi slices draw no outline (owner, #2704): a contrasting outline read as a black border around the mark, so the stitch alone carries the inner contrast. Automatic keeps its outlines, which are the fill’s ink and draw no border. No gap is cut out of the pocket around the slices: the owner judged it unnecessary for a pocket colour near the kiwi green.

Floating is not self-evident, so a floating window wears an on-window mark (#1799). The old reading — a float overlaps a tiled plane, so the window itself says it floats — held only while every float sat over tiles. On a floating-mode space a window set floating looks exactly like its siblings, yet the flag decides what happens when it is dragged onto a tiled space; and a float resized into a gap reads as tiled anywhere. The Space Bar badge answers the question, but not with the bar off, and not for a user who is looking at the window.

The mark reads the floating flag, never EffectiveFloat, the same predicate as the Space Bar badge, so the two surfaces cannot disagree. That includes a window set floating inside a floating-mode space: it wears the mark, because that is the case the flag is invisible in. A window that floats only because its space does wears none — every member would carry the layout’s own symbol and say nothing, the #1286 badge argument unchanged. Do not “simplify” the check to the predicate.

It ships on, behind its own floating.mark switch beside the sticky one — not folded into it, because the sticky switch also carries sticky’s refusal pills, and one toggle would couple two meanings. A window that is both sticky and floating wears one plate with both glyphs side by side, sticky outermost: sticky keeps the top-right square its home-space pill is pinned to (#421), the floating glyph sits just inside it, and the pill grows from the plate’s leading edge so neither glyph moves. Stacked would cover a second strip of the window; one combined glyph would read as neither. Where the window is too narrow for both beside the traffic lights, the floating glyph drops first: sticky owns the outermost square its pills are pinned to, and the outermost glyph never drops. The glyph is the badge’s own macwindow.on.rectangle, so the bar and the window read as one mark. On Liquid Glass the plate carries ONE tint — the outermost glyph’s colour, so sticky’s when both show — because a glass has one backdrop inside it; the flat finish keeps each glyph’s own disc.

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: the offer to create a first override may wait behind a disclosure or a row’s menu; once any override exists, the column shows on every peer row at rest, 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 a longer title). 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 (the KiwiShelf card’s App Bar in Monocle / App Bar in Scrolling): they are the only way a layout carries a bar at all, and they own the App Bar editor’s grey. The class reaches the App Bar’s own fields alone; the shelf’s take no per-layout value (App Bar ▸ one shelf). (#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 the six per-layout override models — BSP, Stack, Scrolling, Grid, Monocle, Track — and one further class ruled below on its own test, a Space’s own gaps. 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 deliberately not per-space. The editor’s saved for other layouts breakdown reads one reflective fieldCount over these six models, so a new override field is counted without a hand-kept tally; the cell’s total adds the gaps class (spaceOverrideCount(for:)). (#290)

A Space’s own gaps are the one override outside a layout. Its first test is its own rather than #290’s: gaps are geometry of the whole Space, owned by no layout and applied by every one. The other two hold as written — they resolve before any layout runs (gaps(for:) feeds every layout’s math), and the global gaps on Gaps & Borders are an unambiguous “off”. So the gaps row sits first in the overrides box, apart from the layout rows, and survives a layout switch; it counts as one in the cell’s total, so a Space whose only override is its gaps still shows it — on a Floating Space as N saved — while the panel caption and the header’s “N of M set” stay layout-only, a gaps-only Space truly following Layout Defaults, where the row stays visible, greyed with its reason, because floating places no windows. The row edits the uniform Outer and Inner masters only; per-edge values stay in Lua (set_gap_override with a table) and read “mixed”, the first drag converging them, as on Gaps & Borders. “Reset All Layout Overrides” leaves the gaps alone: they are not a layout override, and the row’s own checkbox is their reset. This is a second admitted class, not the start of a generic per-Space settings override — each further class owes the same three tests and its own entry. (#1775, owner ruling 2026-10-09)

A Space’s own minimum window size is the third class, on the gaps’ tests (#2422). It floors every window in the Space, whichever layout places it; it resolves before any layout runs, inside the one resolver that fills LayoutContext; and the value its layout would use is an unambiguous “off”, which the unchecked row names (“follows Grid”, or “follows Minimum window size” while every layout agrees). Its row sits below the gaps, apart from the layout rows, and survives a layout switch. Unlike the gaps it is live on a Floating Space, since it floors every float there, so it greys only on Monocle, which applies no minimum.

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. 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. (OverrideCellState, owner ruling 2026-08-04; #678 8a)

A Floating space’s cell never disables: its own minimum window size still applies there, so the cell offers Customize… with nothing saved, and counts as N custom once that value is set (#2422).

Amended by #2674: the summary is now the badge on every Space row’s Customize button, dimmed where this entry says muted and absent with no overrides; VoiceOver speaks it as “N custom”, “N saved — they apply when it tiles again” or “None”. The gaps row’s place in that total, above, reads the same way. The rule it protects — never hidden, never disabled — is unchanged.

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 so the values about to be overwritten are in view. (#68 §3.14)

A master whose followers disagree stays live and acknowledges; it never greys (#1383, owner ruling 2026-09-15 adopting the ui-designer verdict, reversing the issue’s own “greyed but draggable”). Dimmed means “does not accept input” on every channel — mouse, keyboard, VoiceOver — and SettingsSlider refuses the drag, the arrow nudge and focus on the same bit that dims it, so a control that greys and still writes on a drag would be a second axis invented for one row, and a live control drawn dead is the one failure worse than a wrong grey. So in the gap masters the slider is live, the readout says “mixed” and speaks it, the label’s ? carries the edges are set differently right now; a value here sets all of them, and the first edit converges every edge — how a macOS inspector treats a mixed selection. Greying was never the gap masters’ special answer licensed by the per-edge drawer under them: the drawer still pre-expands while mixed, but repair is one gesture on the master and never needed a grey to protect it. A “Sync” button was rejected as a second control for a one-gesture job. GapsAndBordersGateWiringTests ▸ gapMastersAcknowledgeAtTheLabel holds the shape.

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. An inline #RRGGBBAA field beside every well turns ten color rows into a wall of text boxes, and the system color panel the well opens has native hex entry in its sliders pane, so the inline field is redundant chrome — 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. Putting all of them on one page under a “save these as a palette” promise makes that a defect: the bridge would drop two of its own rows. The filter 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 the sticky and floating 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 pocket is the exception (owner, #2704): each authored palette paints it one of its own accents, since the pocket is filled opaque and keeps its contrasting stitch, so a fixed colour stays legible over any window, and it sits under the kiwi green, which every bundled pocket colour must clear by the colour-vision floor (PocketMarkColorTests). The cost, a pocket that no longer follows light and dark under a bundled palette, is accepted. The derived default palette is the deliberate exception — it carries all three mark keys, 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.

Automatic has a second kind since #1856: a follower, whose empty value is another colour of the palette rather than an adaptive tint — ColorPaletteKeys.followers names each and the colour it follows, and ColorPaletteKeys.resolved is the one reading of it. It may join only where the colour it follows never empties, so Automatic always resolves to something drawn, and its well shows that colour rather than the light/dark split, which would promise an adaptivity it does not have. Unlike a mark tint, every authored palette carries a follower EMPTY: its default is a pairing the palette’s own colours define, so a palette that left it out would keep the last palette’s ring beside its own text.

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; PaletteHighlightRoleTests holds it). The rejected reading — borrow the secondary as a flourish — fails on 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 a near-neutral, low-chroma grey that must never compete with the focused ring; its lightness and alpha are argued after this list (#1384).

  • 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 macOS notification badge — #B00020/white, byte for byte, the default before #955 — is 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 (it has since taken the lighter #8E8E93 under BLACK ink, which holds 6.4:1, #1769), 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. 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.

  • Drag ghost / drop-zone: a deliberate two-hue split (border opaque + fill ~15–25 %) so origin reads apart from target — and it is held to the same CVD separation floor as the two accents (#511, 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 every bundled palette but Monochrome, Sunset and Ultraviolet, which 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 unfocused ring’s grey sits at 85–90 % alpha with its LIGHTNESS set per palette (#1384), so that the ring composites to less contrast than the focused ring on the palette’s home backdrop — dark for every palette but Clean Light — and never competes with it. A dark grey at ~35–60 % alpha over dark wallpaper recedes twice — the vanish #1384 reported on six dark palettes — and below ~85 % the ring’s colour is the wallpaper’s, which on a busy one is no ring at all. Alpha is not the lever for receding; lightness is — and “receding” is measured as composited contrast, not lightness order, since on a light backdrop a ring recedes by approaching it. Two palettes fix their number so the argument is not re-run. Sunset’s grey stays dark (#6A665EE6, ~25 L* below the pink) because #FF8099 simulates to a neutral grey under protanopia, so only lightness separates the pair and a lighter grey walks into the collapse — BorderRingSeparationTests ▸ aLiftedSunsetGreyCollapses pins that the shared system grey the other dark palettes take fails Sunset outright. It is the weakest ring of the set on dark and accepted; if the pair reads too quiet, lighten the accent one step with hue held, never the grey. Ultraviolet’s grey sits level with its indigo (#7E7E96E6, blue-grey cast kept) rather than below it: a grey clearly under the indigo’s composited contrast is the vanish again, and the device pick #BFBFBFE6 composited to more than twice the indigo’s, inverting dominance — BorderRingSeparationTests ▸ theRefusedUltravioletPickInvertsDominance measures it — so contrast parity with the indigo’s chroma carrying the order is the compromise. The derived default keeps #8E8E93CC, the one value under the authored band. BorderRingSeparationTests holds the rest: the pair’s CVD separation composited at both wallpaper extremes, the alpha band, opaque focused rings, the home-backdrop dominance derived from each palette’s own fill, and a floor on the grey’s own home contrast — the lightness half of the vanish, which a near-black grey at in-band alpha would otherwise clear on every pair clause.

Nightfall’s focused accent is orange, not its theme’s magenta. Nightfall borrows the night-blue family of a well-known editor theme under its own name. It uses no credit line, since colour values name no one and a borrowed name would read as an endorsement. The theme’s signature pair is blue with a magenta. Under protanopia that magenta separates from the blue by 0.16/441 against a floor of 60, so a protanope would see the focused window and the active Space as one colour. The focused accent is the theme’s orange (#FF9E64) instead: its cyan misses the floor as well, its red reads as an alert the focused window does not carry, and orange is what every other blue-accent palette (Clean Light, Slate, True Dark) pairs its blue with (SpaceBarAccentSeparationTests). Its tightest margins are the idle identifier and the section divider over a white wallpaper; IdleItemContrastTests and ShelfDividerWeightTests hold both over every bundled palette.

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. site/src/styles/theme.css follows the same rule: it themes the Starlight title ink while its header mark stays fixed. 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. (There is no runtime Dock icon to serve — 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: 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. The master keeps the lettering and the mark’s tiles as separate paths (see assets/README.md): a fused path can only be recoloured whole, which is how the pre-#439 gold recolour of the entire logo, symbol included, came to exist.

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 does not share the ring’s hue — see the origin/target paragraph below (#511); the 3:1-at-both-ends bar is what it keeps. The default ring width is 5 pt rather than 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 what lets the default sit on a livelier green than the duller #567A1F at the same lightness. 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. The ring’s ~12° hue shift (#578) is the one place the green-forward identity’s rule for ink and borders — keep the hue, drop the lightness where a role needs contrast — is broken: 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 ring has no partner to separate from, so nothing asks it to move for CVD; its own shift to #4A9816 is the unrelated moss reason (#578). 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 all but those three, without which Clean Light, Slate and True Dark ship 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; #293 merged both bars into one Bars destination, and the #678 Phase 2 redesign made it one page of two cards, the per-layout overrides gone per the §3.4 rescope above.) Bar configuration is too deep to live inside the structure page — Appearance then, Gaps & Borders now — and stays out of it: Gaps and Drag & drop are 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, so it is 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. The bar colors live in Looks & Animations ▸ Colors & glass (#678 Phase 3, #2389, “Colour collects in one area” below). Since #1857 none of them sits behind a second drawer: once the Colors drawer is open (#2593), the accent ones lead in its role groups and the rest are all shown under KiwiShelf colors — the Fill aside, which sits in the open Liquid Glass group above the two rows it greys — because a browse should not hide half its subject.

[Principle] Colour collects in one area, and a colour renders there and nowhere else. (#678 Phase 3, 2026-08-02; #2389.) 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. The failure: 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 user’s whole colour surface at rest 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.

Amended by #1857 (2026-10-11): the accent groups by role. That premise fails for one colour. “My green” is one thing a user notices, drawn on four surfaces — the active Space, the bar’s highlight, the focus border and the drag ghost — and the palette rules already treat it as one role, held apart from a second accent by the two colour-vision pair floors (active item against the Space Bar’s focused window, the ghost against the drop zone). So the colours open with two role groups side by side, Accent and Second accent, which are exactly one half of each pair, and groups by surface everywhere else, where the premise holds. A role group is not a second home: every colour still renders in one place, which is why the ring’s row there is “Focus border” and “Focused window” names only the Space Bar ink. Grouping by role elsewhere — the shelf’s item and fill, the window marks — would invent a role no palette rule defines.

A role group’s colour writes its rows; it is never a colour of its own (#1857). Retinting a look used to mean editing each of the accent’s wells in turn, so each role group carries one well, “Ticked rows”, whose pick lands on every ticked row of the group — at that row’s own alpha, since a hover fill and a ghost fill are the accent at their own strength, and a pick never makes a stroke translucent. It stores nothing: the writes land on the existing leaves in the draft, so Revert is the undo and nothing migrates. The ticks are scratch, re-seeded each time the page opens from the palette itself — a row starts ticked when it already draws its group’s first colour, ignoring alpha — because ticking by meaning would undo the palettes’ own splits: the shipped default darkens its ring for stroke contrast and draws an emerald ghost for the drag pair, and a seed by meaning would paint the bar’s green onto both the moment the page opened. A dimmed row is left alone, and a row whose empty value follows another colour takes no tick at all, or a pick would pin a hex over that pairing. While the ticked rows differ, the well shows the first and says “mixed”, the group’s title carrying the ? (#1383’s master shape); and a field’s commit that names the colour already stored writes nothing, so focus passing through the well never repaints the group.

There is deliberately no colour-vision warning on the pick (owner, 2026-10-11). The separation floor holds the BUNDLED palettes to what every reader can tell apart; a user picking their own colours is choosing for their own eyes, and a reader who cannot tell the pair apart sees that on the bar without being told. The guards keep the bundled half.

The cost is real and is paid deliberately: every gate on a colour row names a switch on another page, so “why is this greyed” has to say where to go, on a live ? outside the dimmed rows.

Amended by #2389 (2026-10-20): one area, inside Looks & Animations. The split by kind stands; the second page does not. Two pages for what a user reads as one decision (“what does this look like”) cost a Simple user the individual colours outright — the page was Power User only, so a palette could be picked but never one of its colours fixed — and cost a Power User a crossing between pages for every retint. So the colours moved into a collapsible section, Colors & glass, directly under the palette shelf they refine, shown to everyone and shut by default; Liquid Glass and the sheen moved in beside them, first, since the finish changes how every colour below it reads. “A colour renders in exactly one area” holds unchanged — the area is now Looks & Animations (SettingsColorSurfaceTests). What keeps the surface simple is the shut section: opening it is the “on demand” step, so nothing inside is hidden behind a second “More”, which would add a decision and a second search hop for no new protection. The section’s rows are .showMore and take search anchors (the #277 entry above), and “Advanced Colors” stays a search synonym of the page that took its content, per the title rule below.

Amended by #2593 (2026-10-26): the finish is open, the colours are the one drawer. Since #2569 Liquid Glass, the Fill, Tint KiwiHub and plates, Fill fade and the sheen are choices made for the whole look, like the palette above them, so Colors & glass is now an always-open section that shows them at rest — the Fill directly above the two rows it greys, so their grey needs no sentence naming another group. Every other colour sits below them in ONE drawer, Colors, shut on every visit with no stored flag, as Motion’s is. This is still one drawer, so #1857 holds: the colours remain the one “on demand” step, and search stays one hop, since a hit on a colour opens the drawer (#1250). What moved out of hiding is the finish alone, for everyone. Its rows are .atRest, so by #277 they take no search anchor: a hit lands on the page, where they are already shown.

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 page sits after the things it paints: 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 above, since the magnitude test would misread macOS’s clamp 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. Because the destination is the cursor display’s active space, 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 dropped on another display joins that display’s Space too, at the drop (#1686). A float’s Space is where it parks and returns, and where its bars and region are judged, so one left behind on the screen it came from was owned by one display while drawn on another — tiling it again sent it back across, and switching the old display’s Space parked it out from under the user. The drop is the moment the user says where the window belongs, so the re-file is taken there, through the drop-commit relocate above, and it is the exception to #502’s scale-and-re-anchor on a crossing: the pointer placed the window, so its frame is the drop’s — the bar clamp aside — and the drop’s live frame is folded before the relocate’s retile, whose nets would otherwise judge a lagging echo. Re-filing later, when the window is tiled again, was refused: it leaves the window mis-owned in between. A floating-mode member — floating only because of its Space — joins too, and onto a tiled Space takes the float flag, since the layout would otherwise tile it: it was floating and stays floating, and the flag it never set is the price (owner ruling 2026-09-26, over leaving it home and over tiling it). The flag is the one make_floating records, so detection cannot re-tile the window later and it reopens floating — a window that tiled itself minutes after a drop is the worse surprise, and Tile undoes it. A sticky window is not re-filed by the drop at all: its home is #445’s to move, and that gate’s refusal cue on a window the user just visibly moved would mislead.

[Principle] A float crossing displays scales to fit by default; keeping the exact size is the opt-out. (#502, supersedes #444/#493.) Rationale: “size is the user’s choice” — #444/#493’s reading, which kept a float’s exact size on a cross-display re-anchor and rejected shrink/center as the default — fails in QA: 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 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_placement 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 toggling a column’s border or fill 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; the stroke’s width and corners belong to the page’s shared card and the alignment to Lua alone (#754). The narrowing that lets a half-width row hold a colour field (dragColumnLabelColumn) and the in-group short form it is for (“Border color” → “Border”, with the full name kept for VoiceOver through a11yLabel) travel with those rows: they live on in Colors & glass’ twin drag columns, which take the width as ColorsAndGlassRow’s labelWidth:, and this editor no longer pushes the narrow axis in through settingsLabelColumn at all — what remains here is toggles, which draw their own labels. Wherever the ghost and drop zone are drawn — the Gaps & Borders panel’s composite scene (#793) — the drawing shows the stored alignment, which only Lua sets, and the width and corners the markers actually draw. Schematic, not pixel-exact, and it remaps the full value range instead of hard-capping halfway. 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. A “Default” segment earns a three-way picker only while the shipped default cannot be expressed as one number (a fixed pt horizontally, a fraction vertically); with both axes’ standards the same fraction it is 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 pitch — 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 pitch 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 a 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 style for every window stroke — stored once, never three stores under a master. 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 there is one answer and one place it is kept: border.width and border.corner_style are every window stroke’s, derived once as TilingSettings.windowStroke, and the ghost and the drop zone store no width or radius of their own (#1742, owner ruling 2026-09-28). Gaps & Borders asks each question once, in a card above the sections that draw the strokes.

The rejected shape is a Use one width for all borders toggle over two masters, with the per-stroke sliders left on screen and dimmed, 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.

A GUI master over three stores is that toggle one level down, and it went the same way. The card’s first shape (#754) kept the three stored widths and the two corner shapes, wrote all of them from one Width and one Corners row, and left the per-stroke verbs open in Lua. The stores could then disagree — through Lua, or through any writer that forgot one of the followers — and the card had to answer a state no GUI user could make on purpose: a ? saying the strokes were set differently, a Corners picker with no segment selected, and every new writer owing a fan-out to all of them. That is the toggle’s stored pick and dimmed rows paid for in acknowledgement instead. With one store the rows write the border directly, a look and border.set_width mean the same thing, and no divergence exists to explain. The cost is a Lua user who wanted three widths or a hand-tuned drag radius; “the GUI curates, Lua stays open” keeps Lua open to a decision, and a per-stroke width is the oversight above, not a decision. The per-stroke verbs are retired through APIReference.retired, naming the border’s verb, and take no alias (AGENTS.md §5).

The shared corner is a style, not a radius. The ring stores a two-value corner STYLE and matches each window’s real radius; the drag pair stored a 0–40 pt radius. A radius cannot be put to all three strokes — the ring’s comes from the window, not from the user — and deriving a style from a radius (> 0 ⇒ rounded) collapses a slider into one bit, so 1 pt and 40 pt drew an identical ring. The style can be put to all three: Square draws every stroke with no rounding, and Rounded draws the markers at the system window radius and the ring at its window’s own.

The ring wins, and no migration carries the rest. A config written before the one store may hold drag widths or a radius that disagree with the ring. The ruling keeps the ring’s values, so nothing the drag keys hold survives, and a ConfigMigration step would have nothing to carry: AGENTS.md §5’s crossing exists to move a VALUE to its new spelling, and one the ruling discards has none. The decoder no longer asks for drag.corner_radius or either visual’s border_width, an old file stays readable beside them, and the next write of that file drops them (WindowStrokeTests ▸ oldDragKeysAreIgnored). Nor is §5’s meaning-change crossing (#1354) owed: border.width and border.corner_style keep their unit and scale and only widen their reach to every stroke, which is the change the ruling decided, so a stored value reads as it did.

[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 shared row. 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 bars ship where macOS already puts a persistent strip. (#660, #1517; the fill number retuned by #755.) KiwiShelf on top, both bars 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. The top edge is where the menu bar has 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 a translucent default is a bet on the user’s wallpaper: the 40 % fill #660 chose 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 the fill landed is a separate decision (#755). Read side by side, the nine bundled palettes’ fills spread from 40 % to 85 % and the readable ones had converged on one value on their own — so every bar fill KiwiDesk ships carries that one alpha, the built-in default included. One number rather than a range because 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. 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 — every one of these values stays 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.) The App Bar draws each window’s title, and the Space Bar’s front segment shows the focused window’s title in place of its app’s name.

The App Bar’s dim means “not fully on screen”. (#2234, owner rulings 2026-10-15 and 2026-10-18.) It used to mean “not focused”, which says nothing the indicator does not already say. The question a user brings to the App Bar is which windows are on their screen, and with it which window a focus change would move: focusing a scrolling column that is cut, or a tile stacked behind another, pans or restacks the layout, and focusing one already in full view moves nothing. The dim stays binary, and focus keeps the indicator and the accent title, so it still reads apart from the lit tier.

Each layout answers it as simply as it can. Monocle shows one tile, so the one its layout puts in front is lit and every other is dim wherever it is parked, with no frames read: monocle looks as it always did. Scrolling is the one layout judged from frames — the ones it issues for the App Bar’s Space on its screen, against the area it fills, in the order it stacks them (the scrolling raise order with the window in front on top). A float never counts against a tile: the App Bar answers what is on screen, and floats are the edge case, so a tile under a float stays lit. A float itself is judged against what does cover it. The focused window sits above every float — a quiet raise cannot beat the frontmost app’s key window (#2284) — so a float it covers wholly is dim, one it overlaps reads partly with no side, one the screen’s edge cuts is dim, and the rest are lit. Two unfocused floats are not judged against each other, their order being unknown.

A WindowServer read would see real overlap, but it would put a compositor round trip on a refresh path that runs on every switch and focus report, and that read stalls under GPU load (#1925). So a window KiwiDesk does not manage covering a tile leaves its item lit. list_windows’ stacking rank is the one compositor read beside the verdict, on demand only (A window’s frame is reported from KiwiDesk’s own placement). While a held pass waits for the hand to rest, the frames are the ones it owes rather than the ones on screen, and the bars lead them as they lead every held pass.

A partly visible item carries a half-moon. “Badges are Space-Bar-only” rules out state badges (sticky, floating), which the App Bar would repeat from the Space Bar. The half-moon states where the window sits in the layout, which nothing else draws. Its filled half is the side of a scrolling column still in view, along the scroll axis rather than the bar’s, so it says which way to scroll. A tile stacked behind another, or a float partly under the focused window, draws the ring empty: it is not scrolled away, so there is no way to point. A float never takes the filled half — its edges are where the user put it. The slot is reserved on every item, so a window sliding out of view changes no item’s length, and the disc is drawn at full alpha whatever the item’s dim.

A sliver is not a part. (Owner and ui-designer, 2026-10-18.) A scrolling row leaves every scrolled-out neighbour a fixed sliver so it stays reachable, and a moon on each of them said nothing. So a cut column reads partly only once it shows more than that sliver (ScrollingLayout.edgePeek, read from the layout and never restated), and a tile stacked behind another only while more than a fifth of it is uncovered (BarVisibility.partlyFloor); less reads hidden. A share would not do for the cut: the sliver is a fixed length, so on a narrow slot it is a large share. A float takes no floor: any overlap by the focused window shows the ring.

An App Bar item draws its icon AND its title. (#1528.) A vertical App Bar draws the icon alone, which is rendering, not a setting. There is no content setting because each alternative to both drops half of what an item is for. Icon-only repeats the Space Bar, whose glyphs are click targets carrying the same app and a hover title, so what the App Bar adds is the title. Title-only drops the icon that names the app beside a title that, on the sample below, mostly did not name it. A setting whose every other choice is worse than its default costs a row, three verbs and a per-layout override, and serves no user.

A Space’s floats close both bars. (#1826, owner ruling.) The App Bar lists them after the tiled row, past a thin rule and the floating mark; each Space Bar chip draws its flagged floats last. The App Bar’s items are a map of the row, where order is layout order and a drag reorders it. A float has no slot in that row, so mixed in among the tiles it would read as one and invite a drag that means nothing. The rule says “the row ends here”, the mark names what follows, and the float’s item stays undraggable rather than dimmed. Last rather than first, because the leading end is where the eye starts, and a float appearing there would shift every tile behind it. A row of floats alone keeps the mark, ahead of its first float, and draws no rule (owner ruling, #1838): there is no row end to mark, but what the bar shows still wants naming, or a lone float reads as a tile.

The rule takes the in-item tier, 1 pt at half depth: at the section’s weight it reads, on a fused shelf, as the divider between the two bars a few items away — two marks alike in shape meaning different things. A mark alone, midway in the gap, belongs to neither side, and one pulled against the floats attaches to whichever float comes first once the focus fill moves. So the rule separates and the mark labels. The mark is ONE section marker, not a badge on each item, so the Space-Bar-only badges ruling stands. It keeps idle ink when a float is focused: the float’s own item carries the focus, and a state mark keeps one ink (see The Space Bar’s two-accent model). The Space Bar takes no mark, since each of its float glyphs wears its badge already.

Under Boxed the gap is the rule (owner ruling, 2026-10-21): each item’s box already ends the row, and a hairline over the wallpaper cannot carry a halo, so the rule draws nothing while its room stays and the mark keeps its place. The mark, free over whatever is behind the bar — a white window — vanished at idle ink, so on Boxed alone it takes the full item ink and a halo in the shelf’s Fill, overriding the idle-ink clause above; a light Fill flips both with it. On a plate both stay as they are.

The App Bar’s set is the one the directional focus keys reach, floatingFocusCandidates, not a narrower “floated by hand or by rule”: the #683 transient-overlay filter already drops dialogs, sheets and panels. The Space Bar’s sort takes the badge’s flag (#1286) instead, so the glyphs gathered at the end are the badged ones — a fullscreen float among them, which the App Bar leaves out as the focus keys do; an effective-float key would gather a whole Floating-layout Space and sort nothing. No badge on the App Bar item: that bar shows no state badges (see Sticky has no native cue), and VoiceOver speaks “floating window” on each float item.

A focused float takes the highlight, and the bar scrolls to it. A float’s focus pans no window, but the bar’s scroll is not the row’s: it exists to keep the focused item visible, and a highlight past the fade is no access point at all.

A stale enum spelling costs the FILE, not the field — the price the config format charges everywhere. TilingSettings decodes each bar style 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. v0.9.7’s retired content spelling was in every profile that build 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 its app-wide fields, never settings.

Leniency for a renamed field is 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 one field. Every enum in the bar styles throws 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.

The crossing is a migration, not a lenient decoder, and the difference is that one of them ends. ConfigMigration rewrites the file itself, once; a decoder that folded a retired spelling would keep accepting it forever, because nothing ever signals that the last config carrying it is gone. “Re-editing the config IS the migration” held while this repo had one user; v0.9.7 shipped to others (AGENTS.md §5), and asking a stranger to hand-edit JSON to get their profiles back is not a migration policy.

A key that is dropped takes one step whatever it held, and an earlier value rewrite of that key folds into it: a deleted key needs no spelling fixed first, and two crossings over one key are two chances to disagree about it.

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.

A verb the GUI writes into a stored binding is a stored value (#1511). The charter keeps the user’s Lua out of every crossing: a renamed verb in init.lua, or in a binding someone wrote by hand, fails loudly, naming its replacement. Each Settings catalog row, though — an app shortcut, a Space jump, a resize, a layer switch — writes one exact call into the binding’s lua, so that spelling is the app’s own format and a rename of its verb owes the crossing any stored value owes. The rule is by SPELLING: the exact shape the GUI writes crosses, whoever typed it — a custom row typed that way crosses too, and gating on the row’s kind would be wrong, since a missing kind decodes as custom — while every other spelling still fails loudly. Without it a rename breaks every shortcut ever made in Settings on the update, which is the stranger-hand-edits-JSON outcome above.

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 the icon beside a 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. On that same sample the app names ran 6–20 characters and the titles to 57, so a slot sized by its titles would grow with the longest until slotLength’s quarter-of-the-bar clamp bit and the rest of the bar scrolled.

So the cap is what keeps the slot usable at all, and with no pinned item size it is the App Bar’s one size control. Every title draws in a slot as long as the cap in the bar font’s average glyph, whatever the titles read (#2545, owner ruling 2026-10-22) — the front-app segment’s rule below. A slot measured from the widest drawn title moved on every title change even with the cap: ten capitals draw wider than ten lower-case letters, and a title under the cap is narrower still, so switching between two capped windows re-sized every slot and re-centred the run. Cutting by width while the slots still followed the widest title was rejected: two capped titles would then match, but a short title meeting a capped one would still jump. With every frame fixed, a show whose titles alone changed redraws content and never runs the frame pass.

The Space Bar’s own cap, front_app_title_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. While no title is drawn a title event needs nothing; drawing one 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 whether a painted bar draws OR announces the title (#937) — so a vertical bar, which draws icons alone but announces the title, still schedules.

[Principle] One shelf holds both bars on one plate, and a field both bars read is stored once, on it. (#1517.) The Space Bar and the App Bar sit on KiwiShelf, one screen edge. Where they hang and the plate they share — edge, alignment, order, the Space Bar minimum, thickness, margins, background style and fit, Liquid Glass, corner roundness, item gap, font size, the app glyph style, the idle opacity of untinted content (dim_factor) and every colour the two bars share — is kiwishelf’s. A field each bar may set for itself stays on that bar, whether or not the other bar has one like it: the active indicator’s shape, the App Bar’s title cap, the Space Bar’s glyph span, spring delay, front-app title cap, its active-Space dim and the colour of the focused window’s glyph inside a Space item are examples, not the list.

The edge left that list with #1731: each bar owns its edge, and one shelf per edge is what holds them — Amended below.

One plate, two sections. While both bars show they are one plate with two sections, Space and App, placed as one unit by alignment; order picks which section comes first. The plate is one surface rather than two painted to match: one panel per display carries it, the divider and both sections, and each bar renders only its content into that panel — two panels each painting a plate is the seam this removes. Under Liquid Glass the plate is a backdrop behind the sections, never a view that hosts them, so no item is ever reparented into glass as the shelf changes (the churn #1315 names). A fill both bars draw side by side is a value two bars must agree on: two plates in two fills on one strip read as two bars competing for the edge, and a user who matched them by hand had answered one question twice. The same holds one step further for every colour drawn on that plate — item, active item, highlight, hover, badge — and the bundled palettes already said so, giving both bars the same value for each; the one exception, the Space Bar’s dimmer item colour, was the same hue at a lower alpha, which is a rule rather than a colour. So the shelf has one set of colours: idle Space identifiers are dimmed from the shelf’s item colour at a fixed alpha, and the divider between the sections is derived from it the same way rather than being a palette role of its own. That divider is the top rung of one ladder with every other rule a bar draws (BarDivider holds the values): heavier than the rule inside a Space item and quieter than idle ink, so the boundary between two bars outranks a detail inside one without reading as an item — and no rung runs the full depth, since a full-height line splits the one plate back into two. Under Boxed there is no plate to split: the divider is the same thin line in the gap between the last Space box and the first App box, with no box of its own. What separates the sections is what each bar shows, not a second surface or a second palette: the active indicator stays per bar in SHAPE, in the shelf’s one highlight colour, and differs by default — Outline on the Space Bar, Edge mark on the App Bar — so the two kinds of “current” never read as one. The seam sits where the two needs put it. A seam held on the centre was refused: it holds a line still by padding the shorter section with empty plate, which is a cost paid on every screen to steady a mark nobody navigates by.

Reserved where a bar draws. The strip is carved from the display’s visible frame in every layout while the Space Bar is on, and only in the layouts whose App Bar is on while it is off; the remainder is the bounds the layout operates inside, and layouts still never learn a bar exists (resolution before layout; layout functions stay pure over the flat array). With the Space Bar off, switching a Space between BSP and Monocle therefore moves its windows by the strip’s depth. That is accepted: the move is the bar the user turned on for that layout appearing, while reserving everywhere left an empty strip — permanently, in every layout that draws nothing there — to spare it.

Shown is not reserved (#1524). Each bar carries a reserve flag, space_bar.set_reserve and app_bar.set_reserve: off, the bar still draws but the layout keeps its strip, so showing or hiding it moves no window — the primitive auto-hide (#761) reveals on. On an edge the two bars share, which is one strip, the edge stays reserved while EITHER bar reserves it; there is no shelf-wide master flag, since the two bars may sit on different edges and want different answers. The flag is Lua/CLI only and never a Settings row: with it off the bar’s panel still spans the strip and takes the mouse there, a dead zone a row would have to explain in a sentence and the reference can. That cost is accepted; sizing the panel to the drawn plate, so clicks beside a short bar fall through, is a later improvement rather than a condition of the verb. It is global, never a layout’s: like the edge, a reservation per layout would move windows on a layout switch.

A split App Bar pays the same price with the Space Bar on — the reflow paragraph of Amended below.

A shelf can hide; a reveal is an overlay (#761). Each bar carries one of three values, always, auto or hidden (owner, 2026-10-19); the on/off switches are retired, hidden taking their place. A bar turned off and a bar shown only on its shortcut look alike at rest, so two of them asked every user to tell apart two states the screen does not, and an off bar’s shortcut doing nothing was a dead key. The App Bar’s value is per layout, replacing the per-layout switch one for one: a switch between layouts with different values moves the windows by the strip, the cost the per-layout switch already carried. An auto edge reserves only a constant sliver — the full shelf’s own rule with the dots’ depth in place of its thickness, so its windows stand their outer gap clear of the dots, which sat lost on a window’s border while the edge reserved nothing — and a hidden one nothing. That makes “a reveal never retiles” true by construction: a reveal only redraws the bars, and setting a value is the one change that can move the windows. Hiding while still reserving the full strip was rejected (the user would give up the information and keep paying the area). An update changes nothing a user sees: a bar stored on becomes always, one stored off hidden.

A fresh install’s starter takes auto (#2515), and only beside the welcome tour’s bars step, which shows what Auto does and offers Always and Hidden in one click: a starter that hides the bars with no page explaining it leaves a new user with a handle they cannot read. A tour without that step seeds always.

Each bar stores its own value, and an edge two bars share reserves for its MORE present bar: always over auto over hidden. Hiding inside a strip already paid for saves nothing, so an auto bar beside an always one stays shown, while a hidden one beside it still shows only on its shortcut, growing into the shelf. This replaced linking the two values (2026-10-18), which existed only because an edge hid as a unit; once a reveal names a bar, a fused edge needs no single answer, and a precedence fold cannot flip on a layout switch any more than the link could, since it reads the stored values and never what a layout draws. A shelf-wide master was refused for the reason the reserve refuses one: the bars may sit on different edges. That refuses a stored, linked value, not a Settings row: the Show master writes each bar’s own value, as the Position master writes each bar’s own edge, selects nothing while they differ, and links nothing — most setups give every bar one answer, so the master is the one decision a new user meets, and Each bar holds the rest.

A shortcut shows only the bar it names. On an always bar it refuses with a pill on the bar, since the verb cannot show more of a bar already shown and must not hide one by writing a setting.

The hover target is a band over the bar’s own handle, never the handle’s drawing and never the edge: one length for every bar, alone or paired, centred on its handle (owner, 2026-10-19). A band along the whole edge, or half of it split between two bars, opened a bar far from the pointer, so the user could not tell what the pointer was about to open; a band under the handle shows the answer before the reveal. It is not the bar’s drawn length either: a busy Space’s App Bar would bring the whole-edge band back. A shown bar is held over its own drawn run and band, so a bar wider than its band stays open while the pointer moves along it.

Two handles on one edge sit as one row where a lone bar would, their bands meeting edge to edge, so a point opens one bar. The mouse opens one bar at a time — while a bar shows, its sibling’s handle stands aside — and each opens at its lone place under the pair; the keyboard merges both into one strip. That replaced two handles over the merged run opening in place with a hover swap: a short Space Bar and a long App Bar put the handles at unequal distances from the middle, where they read as unrelated.

The dots cap at six, so a handle at rest never outgrows its band — a growing band gives up the one target size — following the current Space with small end dots, as iOS page dots do; the open bar shows every Space anyway. On a top or bottom edge a hover names the current Space in its dot; a vertical handle is a dot wide, with no room across it for a name.

Grown, the App Bar’s line counts the windows its bar lists, beside a window symbol (#2567): the Space Bar’s handle tells you where you are, and this gives the App line the same reason to be hovered, a beat before the bar itself shows the windows. It counts and never names — icons in a 14-point handle would not read — and carries no word, so no catalog key and no plural agreement; VoiceOver reads the count at rest. A side edge stacks the number above the symbol and widens across for it, since the count is short where a Space’s name is not; past 99 it shows a plural symbol alone rather than a wrong “99” or a “99+”, keeping the handle beside the Space Bar’s, while a top or bottom edge grows along with any count and may reach past its band. An App Bar with no window draws no handle at all, so the count is never 0; its show/hide there refuses with a pill, as every refusal of a bar’s show/hide does, rather than doing nothing silently.

A reveal onto an edge that shows nothing else slides the whole bar in from the shelf’s screen side, already laid out — out from under the menu bar where it shows, from the screen’s edge where it hides — while the handle fades, and the retract slides it back out the same way, the handle returning as it lands (owner ruling, 2026-10-20). Which reveals take that motion is decided by what the edge draws at rest, never by the stored value: a Hidden bar shown by its shortcut onto an empty edge slides as an Auto one does, while one shown beside an Always sibling joins it (#1838), there being no edge to come from. One setting, reveal_style, picks slide, the earlier grow out of the handle, unfold or fade for every reveal (owner rulings, 2026-10-21): they read differently and none is wrong, so the user keeps the one they like, and Slide is the default. Unfold stretches the handle along the edge to the plate’s length on a spring and then deepens it to the plate while the items rise, and collapses back the same way — the motion the owner approved in a device demo, whose numbers ShelfUnfold keeps — do not retune them without the owner. Every style plays inside one length, the user’s animations.reveal_duration (150–800 ms, 300 by default), its own timings stretched or squeezed as a whole and a retract in four fifths of it — one value rather than one per style, since the user tunes how quick a bar feels, not a style’s choreography. 0 means no animation, as KiwiVision’s fade does, and the expand’s duration takes the same 0; a value between 0 and the band rises to its floor, since a 50 ms reveal reads as a blink rather than a motion (owner ruling, 2026-10-21). Fade cross-fades the bar where it stands, for a user who wants no travel at all. One picker serves Auto and Hidden alike: a Hidden bar has no handle, so Grow and Unfold start from a point at the middle of its screen side rather than offering a second set of styles per visibility. The panel stays still and the content travels inside it: moving the window frame lagged on the device, the compositor moving it per frame and the glass re-sampling, so for the slide’s length the panel’s room runs out over the outer margin and its own edge clips the bar; a grow’s clip is placed in the panel’s coordinates, so its room — the plate and the handle it grows from — holds still until it lands. A grow on an edge with no handle, a Hidden bar’s, starts at the middle of its screen side. A Boxed shelf has no plate, and every style still applies to it (owner rulings, 2026-10-21 and 2026-10-23): an unfold springs each box out of the handle to its slot, the farther ones a beat later, gathering the outer ones first, and a grow plays as that unfold (#2556) — a wipe across the boxes’ span could not retract, and the spread is nearer a grow than a fade is. The stored pick stays Grow, so a shelf switched back to Plain grows again, and the Reveal row says so under the picker while both are chosen rather than hiding the segment. The spread moves each box’s glass host, never the glass, which would drift from its tint (#2095); a fade alone read as cheap beside the other two. The reveal plays under Reduce Motion too, and so does the mark’s hover growth (owner ruling, 2026-10-21; ▸ The bars honour Reduce Motion argues why).

The marks let the mouse through, so the windows under them keep their clicks. The reveal waits long enough that a pointer passing for a title bar does not pop it, never restarting for movement inside the band; the retract forgives an overshoot; a push against the screen’s own edge reveals at once, as the Dock does — inside the band only, as the Dock’s push counts along the Dock alone. There is no click-pin: the band sits over the mark, so the shelf has revealed before a click lands, and a revealed shelf has no click to spare for one — each item already does something, and the shelf lets the mouse through outside what it draws, as the marks do, so a title bar beside the plate keeps its click. While the shelf glides or slides it covers what that motion crosses, for that beat. The show/hide action is the one pin. It stays until the next press by default, because a keyboard user who pressed to look has not said for how long; reveal_timeout hides it again on its own, one value for every bar. No reveal on a Space switch: the dots already show the new Space. A look never carries the mode, which is functionality, not styling.

One placement rule, asked by every picture of it. Where each section sits along the edge is decided in ONE pure function, ShelfArrangement, which both live bars take their segments from and which the Settings preview and the alignment note ask too. A second copy — a preview laying the bars out by its own arithmetic, a note deriving “the Space Bar moves” by hand — is a picture that can claim a placement the engine does not make, the schematic rule’s defect (#702) on the one surface where the user decides where the bars go. When an App Bar section appears or leaves under a centred or far-end alignment the plate grows or shrinks and slides to its new place over a decelerating ease with no overshoot, through BarMotion — a re-placement the user did not ask for must be seen to travel, never snap — and under Reduce Motion it arrives without travelling. Under Boxed there is no plate to glide; each box slides on its own.

A group folding or releasing its members rides that same glide (#1831). The fold changes the App Bar’s length, so the shelf re-places the section while the members slide: two paces would read as the members settling and then the bar moving again, so one glide carries both, and the item slide’s shorter pace, which reads as a snap for this travel, is kept for every other render but a Space switch’s dissolve (below). On a boxed Liquid Glass bar only the content travels — a folded member leaves its glass at once and a released one takes its glass when it lands — because a glass sliding under another refracts through it, and every moving glass re-samples its backdrop each frame.

A section grows out of what it joins, a lone shelf fades, and the glide’s pace is the user’s (#1838, owner ruling). Motion shows where a thing comes from. A section joining a fused shelf comes out of the section that made room for it, so it grows out of that one from the side facing it and fades in, and one leaving shrinks back into it and fades out. A shelf on an edge of its own has no neighbour to come out of — only the windows stepped back — so it fades in where it lands and fades out where it leaves, the plate included, one shape on every alignment and style; a wipe from the middle of an empty edge would invent an origin, and it is not what the platform’s own chrome does. The fade-out draws over the edge the windows have already taken back, briefly: the layout never waits for chrome, since holding the strip reserved until the bar had gone would lay every new Space out twice. A section already on the shelf glides from where its content was drawn: it re-lays for the new slot at once, so a glide from its old frame first threw the content aside. Content that keeps its offset inside the section — a switch between two Spaces that both show an App Bar — glides from the old frame, length included, the new row revealed from the anchored end; content re-anchored in its slot, centred across a lone strip or at the end of a fused one, starts at the new size placed where it was drawn, since the old bounds would clip it. A switch between two Spaces that both show an App Bar changes the row’s SUBJECT, so the row dissolves: the old items stay where they stand and fade out, cropped by the section as it shrinks, while the new fade in at their slots, both from the first frame — holding the new row back left the boxes empty between (owner, device 2026-10-01) — the old row going over BarMotion.dissolveOutShare of the glide so a longer one is not seen beneath the new at half strength midway, while a slide would claim the same windows moved, and a cut, which is what a discarded view is, made the shrink read as the new row simply placed. On a boxed glass run each box dissolves as one unit with its tint, through a host view of its own that the pair is minted in (#1842): a glass’s OWN alpha shows the tint behind it bare, black for a moment on the shipped look, while an ancestor’s alpha composites the refracted pair together (both device, 2026-10-01 and 2026-10-08), and a glass animating its width re-lays its content every frame, so a box never resizes on a dissolve. A window closing within a Space still leaves at once, and a group still folds its members. The curve stays decelerating — the motion answers the user’s switch, and a curve that eases in reads as lag — while its length is theirs: animations.expand_duration, 500–2000 ms, 750 by default, since the fixed plate glide, just over a quarter second until #1838, read as a snap for a bar coming and going; on_expand turns it off, outside the window animations’ master.

A minimum, not a share. Each section is as long as its items while both fit. Once the shelf is full the Space section shrinks, never below the Space Bar minimum, and the App section takes the rest and scrolls. A split would tax a bar that fits to make room for one that does not; a minimum names the one guarantee a user asks for — how much of the Space Bar stays visible — and leaves every other length to the content. It is a floor on shrinking and never a length the section is padded up to: a Space Bar needing less keeps its own length and the App Bar gets the rest. The Space Bar is the one guaranteed because its items are a short set navigated by position, where the App Bar’s are the list built to scroll. The minimum is clamped between a hard floor — the active item and, each side, a fade and an item gap, so the Space the user is on is never cut or half-faded — and the Space Bar’s natural length. The divider is a plain line while everything fits and nothing hovers on it; only while the shelf is full does its hit area take the resize cursor along the edge — held while another app is frontmost through the window server’s private background-cursor property, the hover ink standing alone where that is absent — and the line its hover ink, a drag writing the minimum and a double-click resetting it — a handle that moves nothing while nothing is hidden is chrome with no job. The drag re-lays the bars as it moves and commits on release through the same setter Lua and the CLI call, so there is one writer of the minimum and one clamp.

Overflow fades; it has no arrows. Each section scrolls on its own. A hidden side fades the content itself — a mask on the items, not a gradient laid over them, which would paint a colour over Liquid Glass that the glass does not have — starting well before the edge, and a count sits on the faded end — the number with a chevron pointing where the entries are, stacked below it on a horizontal shelf and beside it on a vertical one, showing the Space items’ hover chip under the pointer — the item beneath it stands its own hover down there — and keeping the arrow cursor, since macOS keeps the pointing hand for links; clicking it pages, and a side already at its end shows nothing. A side fades and counts as soon as its edge cuts any entry, not only once a whole one is hidden: a clipped entry with no fade beside it reads as the end of the list. A page lands on an entry boundary — the first entry not wholly clear of the far fade becomes the first clear of the near one — and goes to the end where less than an entry would remain, so paging never stops a sliver short of an end; the wheel and trackpad stay fluid, since only a click has a step to align. Arrows cost a fixed box at both ends of every section that overflows and draw chrome that reads as items; a fade costs no room and says the same thing. The fade scales with thickness, clamped, and never takes more than a fraction of what the section shows. The count keeps its chevron because a bare number beside a glyph reads as that glyph’s badge. The wheel scrolls along the shelf on either axis, a trackpad smoothly with momentum, natural scrolling respected, and a manual scroll — a page, the wheel, a drag’s autoscroll — holds until the active Space or focus changes or the section hides: a section that followed the active entry on every refresh would undo the scroll the moment anything redrew.

The front-app segment hides while an App Bar shares the shelf: the App Bar already marks the focused window, and two marks of one fact on one plate is one too many.

An overflowing run keeps the end pads a fitting one has, where its alignment puts them (#1830): its viewport is the section less those pads, so crossing into overflow starts the scroll and moves neither end, and a divider drag that shrinks the Space section moves no outer margin. The hard floor under A minimum, not a share adds those pads to the active item and its fades.

A shared field is stored once. A value two bars must agree on, stored twice, is a question the user answers twice and can answer inconsistently — two thicknesses on one strip is not a look but a conflict the layout would have to arbitrate. The copy action (“Copy sizes and style from Space Bar…”) existed to keep those copies in step and went with them. The same reasoning takes the per-layout overrides of shared fields: a Monocle bar at 44 pt beside a BSP bar at 32 is a shelf whose depth changes with the layout. monocle.set_app_bar_* / scroll.set_app_bar_* keep enabled and the App Bar’s own fields, which is the #678 Phase 2 boundary intact for everything that is a bar’s own. The glyph style joins the shelf for the same reason one step further: one app drawn in two icon styles on one plate is a mismatch, not a choice, so the four set_*icon_source verbs retire — the per-layout two included, which narrows what Lua reaches by exactly that — and the style is one Settings row beside the font size. Every per-bar and per-layout setter of a shared colour retires the same way, each naming its kiwishelf.set_* replacement. Liquid Glass is one leaf for both bars, so the one switch writes the shelf’s leaf beside each other glass surface’s, and no per-layout glass can disagree with it.

No Gap indicator. Gap marked the active item by leaving the plate out around it. On one plate that hole reads as the seam between the sections, and on glass as a rendering fault, so it is a defect rather than a style and is removed: a stored gap becomes outline once, and Lua refuses gap naming the values that remain.

No item size. A Space item sizes to its content and an App Bar slot to its title cap (#2545), between the icon square and a quarter of the whole shelf edge — measured on the edge rather than the App section, so a slot does not shrink when the Space Bar joins. A pinned item size answered only what the title cap already answers, and a second size knob beside the cap doubles the question “why is this slot this wide”, so it is retired and the cap is the App Bar’s one size control. The Space Bar’s cap measures a different string — the front-app segment’s title, inert whenever that segment is hidden — so it is named for it: front_app_title_cap.

The content’s size is named for what it produces. How large an item’s glyphs, counts and automatic text draw across the shelf is glyph_size (#1713), automatic by default and never larger than the thickness, and not the item_padding it replaced before shipping: a padding names the mechanism, so a user who wanted smaller icons did not look for it. It is not an item size either — that is the length-along-the-edge knob retired above — and it moves nothing along the edge. The stored value is kept as typed and clamped where it draws, so a thicker shelf brings a larger typed size back.

The crossing. A saved profile or bundle is rewritten once (KiwiShelfMigrationTests): the shelf takes the Space Bar’s values — its colours and glyph style included — because it is the bar shown in every layout and so the one the user was looking at; the App Bar’s where the Space Bar is off, since then the App Bar was the only bar there was. One colour reads the other way: where the Space Bar’s item colour is the App Bar’s at a lower alpha, the shelf takes the App Bar’s full colour, since the dimming is now the rule’s and keeping the dimmed value would dim twice. The other copies drop, per-layout App Bar colour overrides with them, and a palette is rewritten the same way (a palettes.json format bump, and the bundle’s). An exported palette file carries no format stamp and, like a backup, is never rewritten, so its import runs the same pure step in memory instead: refusing it would strand every palette a user exported before the shelf, and importing it untouched would drop its bar colours at the key filter, since those keys no longer exist. Where the Space Bar is on, an App Bar that sat on its own edge moves to the Space Bar’s. Where the App Bar is the source and never stored an edge, the step writes its old default, bottom: absence meant bottom when the file was written, and reading it as the shelf’s new default would move the one bar the user had to the top. That reading of absence is true only of a file older than the shelf, so the step stands down on any file stamped at the formats it introduced — profile 8, bundle 12 — where an absent kiwishelf.edge means the new top. The App Bar’s default indicator flips to Edge mark, and the same step writes outline into any stored App Bar that names none, so the flip reaches a fresh setup and never an existing one. The Space Bar share was never released and is renamed to the minimum outright. init.lua is user code and is not rewritten; a retired verb fails loudly with its replacement named, which is its migration, and there is no alias (AGENTS.md §5), because an alias is the crossing that never ends. In init.lua that failure is its own Config Issue, never the unknown-call issue’s “did you mean”: the call is spelled right, so a nearest-spelling guess would send the user to the wrong fix, where the retired list knows the replacement (or that none exists) for certain.

Amended: each bar owns its edge, and one edge fuses them. (#1731, owner rulings 2026-09-28.) The edge is each bar’s — space_bar.edge, app_bar.edge — and “one shelf” means one shelf per edge. Two bars on the same edge are exactly the shelf above: one panel, one plate, order, the minimum and the divider. Two bars on different edges are two bars, each on its own strip with its own panel, placed by the same ShelfArrangement as a lone bar. Two bars are never stacked on one edge: that is a second reservation under another name, the cost the shelf exists to remove. Moving one bar’s edge while they are fused splits them, which is the only way to ask for it. Every look stays the shelf’s — thickness, margins, colours, glass, border, font, item gap, glyph size and alignment — because the argument for storing a shared field once does not depend on the bars touching: two bars in two fills still read as two products. Alignment is one value applied on each bar’s own edge rather than a value per bar: a second alignment would do nothing while the bars are fused, which is how most setups run, and would need a precedence rule there; it can be split later with one migration step if a setup asks for it.

The reflow comes back, and is accepted. The price above — windows moving on a switch into the one layout that draws — is paid with the Space Bar on too once the App Bar sits on an edge of its own: that edge is reserved only in the layouts whose App Bar is on, so a Monocle Space is laid out with a strip a BSP Space does not have, and switching between them moves the windows by the strip. Reserving the App Bar’s edge in every layout is refused for the reason the fused rule gives: it would leave an empty strip, permanently, in every layout that draws nothing there, to spare a move that is the bar the user turned on appearing. docs/accepted-limitations.md carries the row.

At a corner the Space Bar keeps its edge. Two bars on adjacent edges would overlap in the corner square. The Space Bar draws in every layout, so its strip runs the whole edge and the App Bar’s stops at the Space Bar’s reservation; the other order would move the Space Bar each time an App Bar appeared. Each strip stopping at the other is refused for the same reason. The Settings preview gives the corner to the same strip.

The front app still yields to the App Bar. While an App Bar shows on the display, on the Space Bar’s edge or another, the Space Bar’s front-app segment stays hidden: the App Bar already names every window and marks the focused one, and a second name for it elsewhere on screen is the duplicate the stand-down removed, whatever plate it sits on.

The App Bar’s edge is global only. No layout overrides it: an edge per layout would carry the bar across the screen on a layout switch, which is the reflow above made visible.

The crossing keeps every setup as its owner left it. A saved profile or bundle is rewritten once: a stored kiwishelf.edge is written into both bars’ edges, so a setup the shelf fused lands fused where it was, and the key drops (profile format 11, bundle 16). A file from before the shelf, read for the first time now, keeps each bar’s own edge instead — the shelf’s step leaves a global bar’s edge in place and writes its pre-shelf default (Space Bar top, App Bar bottom) where it stored none, since absence meant that default when the file was written — so a setup split before the shelf stays split rather than being fused by a rule that no longer holds. kiwishelf.set_edge retires naming space_bar.set_edge; the per-layout App Bar edge verbs name app_bar.set_edge; and the bars’ own set_edge verbs are live.

Amended: a bar’s edge may differ per screen, and nothing else does. (#1948, owner rulings 2026-10-04 and 2026-10-09.) A landscape screen between two portrait ones wants its bars on its long sides while its neighbours keep theirs on top, and only the edge causes that problem: thickness, margins, alignment and glyph size do not, so they stay one value. A screen’s edge is a sparse override of its bar’s — space_bar.edge_override and app_bar.edge_override, keyed by the fingerprint a Monitor pin uses — so a new or unknown screen follows the bar with no setup, and a profile never has to name every screen. It is stored beside edge under the X_override name gap.override already uses, rather than a coined noun.

Giving a screen its bar’s own edge stores nothing: that is “follow the bar”, so there is no third state for a later bar edge to disagree with, and no reset action is needed. A screen-less set_edge sets the bar on every screen and clears the screens’ own edges, as a pick of a bar’s edge — or of Position — in Settings writes every level below it. The entries collapse into the bar’s edge once every screen ends up equal, judged over one set: the active profile’s monitor-set screens, the connected screens and every screen holding an entry. When every screen of that set draws one edge, that edge becomes the bar’s and the entries go, which moves no screen; a screen that draws another edge keeps them, a known screen following the bar included while the rest agree on a different edge. The union keeps a known screen that is away in the set, so a monitor-set screen not connected while a config runs counts as a connected screen does.

A look sets the bars’ edges and leaves each screen’s own untouched, even one it now equals, which stays a pin: the map names this Mac’s screens, which a look shared to another Mac cannot know, and a screen’s own edge is the most specific choice the user made, so it stays the strongest.

In Settings each bar row under Each bar gets a Per screen drawer, one picker per screen of the profile’s own monitor set. A screen with no row of its own (one plugged in after the save) follows its bar, so leaving it out loses nothing. The rows list the profile’s screens, not the connected ones, since a profile names its own screens and an absent one is still part of it; connected screens come first and an absent one carries “not connected”. Identical models share a fingerprint, and so share one row. A bar row whose screens differ selects nothing and says so through its ?, as Position does when the bars differ, and a drawer held open by what it shows greys its chevron. Readers that ask whether a setting has any effect — order and minimum, the front-app colour — ask every screen and grey only where none is affected; the Home card pictures the main screen.

On a profile that holds one screen the drawer’s rows grey, its label staying live and its ? saying it needs a profile with two or more screens (#2677), unless the bar’s screens already differ, which the rows then show. Grey usually says “switch that on and I act”, and no setting turns one screen into two; but a hidden drawer tells a one-screen user nothing about the option, while the ? names the one thing that unlocks it, a profile for more screens, so the grey promises nothing a setting would have to keep.

The shelf’s 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 top) and the shelf renders exactly there in every layout. Axis-relative start/end values, resolved against the layout’s orientation, prevented an edge/axis mismatch while the edge was derived per layout; free four-edge placement removes the derivation and its rationale with it. The Settings preview is edge-aware and draws a left- or right-edge shelf vertical.

The rule is each bar’s since #1731: space_bar.edge and app_bar.edge are absolute, one of the four, default top.

The shelf is placed by ONE rule, and owns two margins. (#1516, owner ruling 2026-09-18.) From the screen edge inwards: the shelf’s outer margin, the strip, its inner margin, then the windows’ own outer gap, then the windows. The outer margin is absolute — nothing else lives on that side, so the value is the distance and 0 is flush. The inner margin is added to the windows’ outer gap, which alone keeps the focus ring’s clearance, so 0 means “the gap governs” and no floor is needed. Both default to 0. Under the pair of rules this replaces — the Space Bar flush with the windows’ outer gap following it, the App Bar carved inside that outer gap with the windows’ inner gap separating it from the windows — raising the outer gap moves one bar away from the border and the other’s window side, and two bars on opposite corners can never be aligned. The alternatives refused: a set margin that replaces the derivation (an inner margin under the ring width would clip the ring, forcing a floor, and “not set” would keep both old rules alive), a single “distance to border” (leaves the window side differing per bar), and a 1 pt inner default (invisible on top of 10; the value means “extra”). The one visible change at update is accepted and owed a line in the release’s Highlights: on the App Bar’s edge the bar moves from inside the outer gap to flush with the screen edge and the tiled windows follow it by the same distance — 10 pt at the defaults, the window side now being the outer gap alone where it was the outer gap plus the inner — and the strip spans the whole edge rather than stopping at the side gaps. A user who wants the old look sets the shelf’s outer margin to the outer gap, which restores both. The Bars preview does not model the margins: at a thumbnail’s scale a few points draw as nothing, and a caption that names a fact the frame does not draw is the schematic rule’s own defect (LayoutSchematicCaptionTests).

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 glyph span depends on grouping running first: an ungrouped mode would burn the span on duplicates while conveying less. That ordering is the settled part; the span’s value is a knob (space_bar.set_glyph_span, default 5, range 1–12, #376, #1528) — group first, then span, whatever the span is. A +n badge counts hidden windows, not slots — the same unit as the per-glyph count badges and the item’s accessibility label.

The Space Bar groups by default; a switch turns it off. (#293, #1725, owner ruling 2026-09-30.) space_bar.set_group_adjacent_windows, the App Bar’s toggle’s twin, collapses adjacent same-app runs into one glyph + count badge; off, every window is its own glyph and one click reaches it, where a grouped glyph costs a menu and a pick (#1528). Grouped stays the default because the glyph span depends on grouping running first: ungrouped, the span counts windows, burns itself on duplicates and fills sooner, conveying less — so ungrouping is the choice of a user who wants the one-click reach more than the overview. With grouping on the order is settled — group first, then span, whatever the span’s value (space_bar.set_glyph_span, default 5, range 1–12, #376, #1528). A +n badge counts hidden windows, not slots — the same unit as the per-glyph count badges and the item’s accessibility label.

The default being the old behaviour is also why the setting owes no crossing: an absent key meant grouped before and means grouped now, so no stored file, built-in layout or init.lua changes.

Both bars group adjacent windows by default. (Owner ruling 2026-10-07, #1946.) Grouping costs a window only if the group hides it, and the peek hides nothing: a group of two or more windows always lists them, and every row in that list picks its window. So grouping keeps the row short — the overview a bar exists for — and no window falls out of reach. Ungrouping stays the App Bar’s option, for a user who wants every title drawn in the row itself, and one default for both bars means a user reads one rule rather than two.

[Principle] A Space Bar glyph reaches its windows; +n never switches by itself. (#1528, owner rulings 2026-09-20 and 2026-09-27; #2063, 2026-10-08.) On every Space, a glyph standing for one window switches to that Space and focuses the window; the rest of the item — identifier, divider, padding — switches and lands on the Space’s last-focused window.

The glyph is the only thing on screen naming a window on a Space you are not looking at, so it is where a click can say which window it means; one target per item left an overview the user could read and not act on. The peek reads its windows when it shows rather than being drawn into the render, so it is current without the bar re-rendering on every title change. A setting to turn glyph clicks off waits for misclicks measured on a device (#1514).

A glyph standing for several windows, like +n, opens its list and switches nothing until a row is picked (#2571, owner 2026-10-24, retiring #2063’s click cycle). A peek row is that pick, as a menu row is: one focus path, whichever list the user picked from. A one-window glyph’s click on another Space switches, because a window on another Space cannot take the focus while its Space stays hidden — the glyph refines only which window the switch lands on. A target standing for several windows may choose only where the user can see the choice. #2063 made the choice a walk the peek showed, because a click on a list the hover had already opened re-read it and read on the device as a click that did nothing. A list that opens on its click alone is the click’s answer, so the click chooses nothing and the user picks the row: a walk would move the focus beneath the list being read, and a second click closes the list.

[Trade-off] A bar item’s hover peek. (#1946, owner rulings 2026-10-07 from an HTML preview and two device eyeballs.) A Space Bar glyph, a +n disc or an App Bar item that hides its text shows the windows it stands for in the bar’s own look, in place of the system tooltip. The tooltip could not be restyled, waited the tooltip delay, and listed the same windows the click menu lists in another face, so two surfaces fought over one list. The peek centres on its item, opening away from the bar’s edge.

The peek acts: every row is its window’s button, on every list, picked on the release inside the row, a drag off it cancelling as a button’s does (#2044). A row-shaped list the user cannot act on is a trap — the hand reaches into it to click a row, and a panel deaf to the mouse lets that click fall through to the window underneath and focus another app, a misclick with a side effect on a surface built to look pickable. macOS shows no such list: Stage Manager and Mission Control act on the surface you previewed, and the Dock’s hover label is one word that promises nothing.

A label peeks on hover; a list opens on click (#2571, owner ruling 2026-10-23, amending #1946 and #2063). A one-window glyph and an App Bar item that hides its text keep the hover label. Every list — a +n disc, a multi-window glyph, an App Bar group — opens on its click alone, on the release inside the item, and its hover opens nothing. A list that popped up while the pointer merely crossed the bar was noise, and the one thing a +n invites, clicking it, did nothing: the click only re-read the list the hover had opened. The Dock draws the same line — a hover shows a label, a click opens a stack — and the overflow mark’s +n opens on click (#2563), so one +n behaves one way. The price is the hover label on a multi-window glyph and an App Bar group, which name their app only once clicked; bringing that label back is a ruling, not a regression fix. A one-window glyph still picks on the click; two lists for one set of windows is the fight the tooltip lost.

A click-held list grows out of its item on the overflow mark’s spring, from a plate the item’s size at the gap’s near edge — the list’s panel sits above the bar and would cover an item it grew from — and fades as it shrinks back to that plate, so no plate rests while the spring settles (owner, device); Reduce Motion cross-fades it in place. It holds until a close: a second click on its item, a press elsewhere, a pick, any key, a Desktop switch; never the pointer leaving, and another item’s hover neither swaps nor closes it. The second press of the double-click that opened it keeps it, so a double click stays harmless (#2063). While it shows, its item stays lit, the one sign of it open: a +n keeps its number, which a window symbol in its place would only repeat with less to say (owner, device, 2026-10-24).

A Space collapsed to its window count is the one list a hover opens: its click is its Space switch (#1945), which a click-only list would take away.

A list’s row for the window holding the system focus — read only on the active Space, the one that carries it — ends in a checkmark, in a column PAST the titles’ wrap width, on every row of every list peek checked or not. A leading gutter is a menu’s idiom, but most peeks name another Space’s windows and never hold the focus, so a gutter would indent every one of them for a mark they never draw; a column inside the wrap width would re-wrap a long title the moment focus reached it. Past the width, a peek gains only trailing padding and no title moves. The check sits beside the first line of a wrapped title, centred on its capitals — the name the user recognises, and a band a tall face cannot drift — in the row’s own ink, the checked row’s hover fill reaching over it. It is persistent rather than a flash: a mark that faded would leave the focus unreadable, and a fact that only snaps needs no Reduce Motion gate. The window menu, the peek’s twin, marks the same window with its native leading check. A pick does what clicking that window’s own bar item does — one focus path for a glyph, its peek row and its menu row, KiwiDesk’s own windows included (#1281) — and is judged when it is performed, since greying on hover needs the compositor read a show and every swap cannot afford (#1925): a window on a Desktop no screen shows takes the glyph’s plain switch to its Space, as the glyph’s click does, rather than a second policy of its own; an App Bar row focuses its window where that bar draws it, as the item does, so a travelling sticky window is not chased to its home Space. The row under the pointer takes the shelf’s own hover ink, so a bar’s hover means one thing wherever it shows. The panel takes the mouse yet never activates, so a click on a row leaves the app the user is in frontmost until the pick moves the focus; it is never key, so Escape is not one of its exits.

A list a click holds open is the exception (#2563, #2571): the overflow mark’s and every bar list but a collapsed chip’s take the keyboard without activating KiwiDesk, so Escape closes them and the arrow keys walk their rows.

The native menu stays as the peek’s twin where the peek cannot reach. VoiceOver’s press on a glyph opens it: the peek is hidden from accessibility, and the menu brings the keyboard and the rotor for free. A list taller than its room ends on “More windows: N”, a button opening the menu, which scrolls where the peek never does. The right-click menu is #1518’s, unchanged. The context-menu chrome a left click wore (#1850) is therefore those two paths’ alone.

Between the item and the peek lies a gap the pointer crosses to reach a row, and a peek wider than its item overhangs the neighbours. A LIST’s peek holds while the pointer stays inside the hull of the item’s peek-facing edge and the peek’s bar-facing edge, with both rectangles: inside it no neighbour swaps in and the peek does not close; outside it the peek closes and cools as it always did. The hull spans no neighbour’s own rectangle, so moving along the bar still swaps at once — a grace that took the neighbours in would make the whole bar sticky. A one-window peek does not hold: it is a label, its one row the item the pointer is already on, so there is nothing to reach for, and a label that lingered over the bar would cover the neighbours the pointer is moving to. Whether a peek is a list is asked once of what it stands for, so a click, VoiceOver’s press and the hold cannot disagree about the same disc.

The hull holds the one list a hover opens, a collapsed Space’s; a list a click holds open ignores the pointer leaving (#2571, above).

It still never opens a menu on hover. A hover-opened menu runs a modal tracking loop that eats the next click, and no macOS surface does that; an untracked panel runs no loop, which is why the peek may act where a hovered menu may not.

What it shows is ruled whole: the app on top, smaller and semibold like the menu’s section header, so the peek reads as the menu’s twin, a step under the titles in ink — the item ink at three quarters of its alpha, DERIVED the way the empty Space ink is: the step is taken only where it holds the idle floor on both grounds the peek draws on, and a palette that cannot hold it, a user’s own included, keeps the full ink; one row per window, hairlines between, so a title that wraps still reads as one window; every window counted, an untitled one as the menu names it, and a lone window titled exactly as its app keeps its row under the header — a header with no row reads as an app with no window, and a row is now something to click; the count as a window glyph and a bare number in the bar’s own badge pill, ringed in the hairlines’ ink only where its fill separates from the peek’s grounds by less than the 3:1 non-text floor — derived on the header step’s two grounds, so a fill that holds its own edge draws no extra line — only from two windows, so it needs no word and no plural — the glyph taking the header’s derived step of the badge ink over the badge fill, so it reads as faint as the app line, while the number keeps the full badge ink; titles wrapped whole, since the peek exists to show what the bar cuts. +n mixes apps, so its groups carry their icons. The face is the bar’s, at one fixed reading size: the strip-depth ladders would shrink it with a thin bar, which is where the peek is most needed. An App Bar GROUP always lists every window it stands for, as a multi-window glyph does, since its name hides the windows whether or not it is drawn whole — where #1514’s tooltip named the app alone; a single App Bar item still peeks only where it hides its title (#1514).

Every group carries its app’s icon, a one-app list’s too (owner, 2026-10-24, #2571): a collapsed chip draws no glyph beside its list, and one look for every peek beats a rule per source.

Amended for the one-window label: it drops what its item shows (#2649, owner ruling 2026-10-26 on a ui-designer read). The rule above holds for every list. A label — one window, on hover — sits beside the item that already draws the app, so its header repeated it: an App Bar label draws its title row alone, since the item shows the icon and only peeks when it cuts its title; a Space Bar glyph’s label keeps the app’s name and drops the icon, since a glyph shows no text and, in symbol mode, no app identity beyond a monochrome mark, so a bare “Untitled” or “main” would not say whose it is. This also retires #1946’s “the app on top” for an App Bar label. The cost is two label shapes, which a source never switches between, against a header that only repeated the item.

The peek’s glass takes ONE tint over its whole height, the ruled exception to the fade a detached surface takes (#1620): the bars’ fade runs across a strip a glyph tall, but a panel of text read top to bottom would end on near-clear glass where a long title wraps. The ink is the shelf’s own item ink, measured on that ground on every bundled palette (PeekInkContrastTests).

Amended: the peek fades like the bar, above the reading floor (#2569, owner ruling 2026-10-24, overturning the one tint above). The peek fades from the shelf’s edge with Fill fade, and so does the overflow mark’s list, which is the same panel. The uniform tint guarded against a long title ending on near-clear glass. The reading floor now answers that by measurement: the fade stops where the peek’s worst ink still reads (▸ Liquid Glass, Reading surfaces fade above a floor). That leaves one fade rule for every KiwiShelf glass instead of a ruled exception. PeekInkContrastTests measures the ink at both ends, a hovered row’s included.

Its height is capped to the usable room on its side of the strip, so a long +n never covers its bar or the item it names. A list taller than that keeps the windows that fit, in order, and marks the cut at the edge away from the bar with a chevron and “More windows: N” in the secondary ink (owner ruling): the chevron points where the hidden rows are — down under a top bar, up over a bottom one, whose peek keeps the windows nearest the bar — and the line is the button that opens the menu holding the rest. The peek itself never scrolls; the menu is the scrollable list. The count stands last behind its label, so no locale has to agree with the number. No title is ever capped on its own.

Timing has one home, BarPeek.Timing. A first peek waits a short dwell, because a zero dwell flashes a label every time the pointer crosses a top bar on its way to the menu bar; once one shows, the next item swaps in at once, and for a short cool-down after it closes, so moving along the bar reads as one peek. The dwell is shorter than Settings’ tooltip delay (Hover help appears sooner than AppKit’s default) on purpose and does not overturn it: a bar item’s peek answers a glance at a strip the pointer passes all day, while Settings’ help answers a reader who stopped to ask. A dwell is not motion, so Reduce Motion keeps it and drops only the fade.

A Space collapsed to its window count (#1683) peeks too, always (#1945; owner 2026-10-04, and 2026-10-18 for no setting): resting on the chip lists every window its Space holds, the list +n gives for its own, and the chip anchors the peek itself since it draws no glyph. The peek is the answer because it is a panel beside the bar: the run does not change, so nothing moves under a resting pointer and the shelf’s plan is untouched. Growing the chip in place to draw its glyphs was the rejected shape — it slides the neighbours under the pointer, which is the stuck-hover class #1665 re-reads hover to survive; the grown chip can leave the pointer outside it, which collapses it again, so the hover oscillates rather than merely sticking; and it changes the length the shelf planned. A click on the chip still switches — the chip’s own action, which a list’s open-on-click would take away — the row’s own click picks a window, and a drag over a collapsed chip opens nothing, the spring switch already showing what it holds. The peek is the pointer’s, as every hover peek is: VoiceOver’s press on the chip switches, and its window list waits on a ruling of its own. It takes no setting: the peek’s dwell already keeps it from firing on a pass, and a switch for it would be one more row a minimal bar does not need.

A Space’s strip centres on its focus, holds under the pointer, and does not scroll. (#1528 items 17–22, owner rulings 2026-09-29 from HTML previews, graded by ui-designer.) The span’s glyphs centre on the focused app — the active Space’s system focus, another Space’s remembered one — clamped to the row, so no cell is ever empty and a side without a +n says you are at that end; a centre with blank cells read as missing apps. While the row overflows, the item is a fixed span + 2 cells: a +n on each side of the span in the middle, span + 1 glyphs and one +n at an end, so a focus change never reflows the items after it — which is why the setting is a span and not a cap, one glyph more showing at an end. A +n hides two groups or more (#2052): a disc and a glyph take one cell each, so a disc standing for a single group spends the cell and hides the app for nothing — the glyph says more, and its own count badge already counts its windows. A side that would hide one group draws it, the strip clamping to that end, and a row of span + 2 groups draws whole. The two +n stay discs rather than the section’s count chips: a count chip pages the section, a disc lists windows, and one shape keeps one verb.

The strip never scrolls. The wheel over the bar already scrolls the section, and a second scroll on the same axis would let where the pointer happens to rest decide what a swipe moves; the two menus reach every hidden window in one click, and centring removes the reason to scroll. A chip under the pointer holds its strip until the pointer leaves, so a second click cannot land on an app the first one slid there; everything else re-centres at once, walking by whole cells through BarMotion — the glyph carried off fading under its disc, the one brought in fading out of the other — and landing at once under Reduce Motion.

The span stays out of a look: it decides which windows are one click away and which sit behind a menu — functionality, with title_cap and kiwishelf.minimum — and every bundled look writes its whole register, so picking one would reset a span the user set.

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 takes the focused accent, not the active-Space one, and each accent 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 is the deliberate exception (#470) — 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 is neutral grey (#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.

The focused app’s count badge rests dim until hovered (#2650, owner ruling 2026-10-26, against a ui-designer read). In the current Space, a multi-window glyph’s badge on the focused app sits at the Space Bar’s middle dim (active_dim_factor) while its icon stays full, and comes to full under the pointer. The designer held that a glyph’s strength marks the focused app, so a dim badge there would read half unfocused and a hover would reuse the focus channel, and preferred a neutral plate behind the glyph. The owner’s answer carries it: the badge stands for the app’s other windows, which are unfocused behind the one in front, so its dim states that truly while the full icon keeps saying the app has the focus; and the cue is owed exactly where a badge is, since a one-window glyph already answers a hover with its label and only a list glyph, opening on a click, showed nothing. This retires the entry above’s “puts the focused app’s badge uniquely at full alpha” for a count badge at rest: the full icon now carries the focus, and a state badge keeps the ladder.

The front-app segment wears the active indicator in its own colour (#1856). The segment is the focused window, so it draws the Space Bar’s outline or edge mark like the active Space does, at the same width, in focused_highlight_color. That colour’s empty Automatic value follows focused_item_color, so the segment’s text and indicator share one hue the way the active Space’s identifier and indicator do (every bundled palette pairs them that way). Drawing it in the active Space’s highlight_color instead was refused: two items would claim one signal, and the two accents above would merge for a colour-vision-deficient reader. The segment’s two ends also pad alike, the rounded end’s clearance at the title’s end as at the icon’s, and so do the App Bar’s items: a ring makes an uneven pair the first thing read.

The same argument reaches the Space chips, which wear that ring when active: a chip pads every rounded end by the same clearance once an icon sits at either end, so its content centres, where it used to pad only the end holding the icon — which pushed an empty symbol Space’s icon off-centre, a marked digit Space’s the other way, and left a digit Space with apps lopsided (owner 2026-10-01, reversing 2026-09-29’s per-end pad). A chip whose identifier is its only content pads nothing where that glyph’s ink fits the chip’s rounded box unpadded, so an empty Space reads as round with a symbol as with a digit. Its icon then shifts by the clearance when the Space’s first app opens; the chip is growing for that app anyway, and the alternative — padding every chip always — gives up the round idle chip, which the owner ruled the calmer state. A symbol too large for the round box pads both ends instead, a centred pill.

Space Bar content is fixed in v1. (#293.) Identifier plus app glyphs — no content chooser, which the App Bar has none of either (#1528). 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 N.square SF Symbol is not the fallback in the bar: a self-bordered glyph inside the default boxed background reads as a box-in-a-box, and the symbol only exists 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 App Bar ships on, on its own edge. (#1528.) The starter setup seeds it in Monocle and Scrolling on the bottom edge, with the Space Bar on top, each on its own shelf. Off by default is the tempting reading once the Space Bar’s glyphs are click targets, and it is wrong:

  • It shows only where windows are out of sight. The App Bar exists in Monocle and Scrolling alone, the two layouts that hide windows behind the one shown or off the screen, so it is the surface that says what is there.
  • It carries what the Space Bar cannot: every window’s title, always visible, in row order. A Space Bar glyph names an app and reaches a title only on hover or through a +n menu, so it tells apps apart and not the windows of one app.
  • The two bars do not double up. The Space Bar’s front-app segment stands down wherever an App Bar shows on the display, so the focused window is never marked twice.
  • Where it lands is the starter’s to say, not the type’s. A default flip on the type changes what every sparsely-stored file means, so it owes a ConfigMigration (AGENTS.md §5); a seed in the starter changes no stored file. AppBarStyle.edge keeps its .top default, and a saved profile keeps the edge it stores.

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 front-app segment has a fixed length. (#2086, owner ruling 2026-10-08.) The segment draws its name in a slot as long as front_app_title_cap characters of the bar font’s average glyph — a longer name cut with ”…”, a shorter one centred in the slot by its ink, the icon never moving — never as long as the name. The icon is what the eye tracks between focus changes, so it keeps its place; leaving a short name at the slot’s start left a dead gap under an indicator that spans the whole chip. A segment sized to its name moved the section’s end on every focus change, which re-centred a centred run and slid the divider and the App Bar beside it: the bar jumped as focus moved between apps. The cap already bounds how much of a title shows, so it is the length to hold; one measured once per face costs nothing per change. With every frame then fixed, a focus change redraws content alone — the Space item’s glyph tints and the segment’s icon and name — and never runs the frame pass, which would re-place glass and views that do not move. The segment joining or leaving a shown bar — the App Bar leaving or joining a fused shelf hands it the place — grows out of the run’s end and shrinks back into it on the plate glide, the motion a joining section makes (#1903), so the two changes of one plan move together rather than one gliding while the other pops.

Amended by #2587 and #2643 (owner rulings 2026-10-24, 2026-10-26): the icon and title centre as one group. A title draws right after its icon, on the front-app segment and on every App Bar item (#2545), and the icon, the drawn title and an App Bar group’s count badge centre together in the fixed length; a long title still fills the slot and cuts with ”…”. A title centred by its ink beside a fixed icon floated away from that icon, so a row of pills started their titles at as many offsets as there were title lengths; a group held at the slot’s start kept each title on its icon but left an empty trailing end under an indicator that spans the chip. Centring the group keeps the title on its icon and leaves no gap. The price is that a short title’s icon moves when the title’s length changes, and that icons of neighbouring pills no longer share one offset. A cut title fills its slot, so its group stays put, and no frame moves for any title. The group snaps to its new place, never glides: titles change all the time, and motion at the screen edge would pull the eye for nothing. The App Bar’s half-moon stays outside the centring, hanging just before the icon, so a window turning partly visible moves nothing; a partly visible pill reads a little left of centre, accepted. The section divider reads an App Bar end item with its title at the full slot (owner, 2026-10-26), so a rename never moves it; beside a short title the App Bar side of the divider reads wider than the other, accepted over a divider that jumps with every title.

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.

Three bar motions play under Reduce Motion: a hidden shelf’s reveal, its mark’s hover growth, and the shelf’s expand (owner rulings, 2026-10-21). The two rulings above stand for every other bar motion. What the three share is that each is the shelf ITSELF arriving, changing shape or leaving — the chrome’s own frame, whose absence reads as a jump, the way a window appearing without its zoom would — while what keeps standing down is motion of content inside a bar already on screen: an item run re-flowing, the spring ring’s sweep, the strip walk. A hidden bar arriving is the bar answering a push against the edge, and macOS’s own Dock auto-hide keeps sliding under the setting (owner, device, 2026-10-21); standing the reveal down to a cross-fade read as a glitch rather than a calmer motion — “otherwise it looks too cheap” — and Fade stays a style for whoever wants no travel at all. The hover growth is the same answer at a smaller size: without its travel the plate pops under the pointer. The expand — a bar growing out of its neighbour, the plate gliding to its new place, the App Bar’s row dissolving to the new Space’s — is the shelf’s shape following the switch the user made. It is named expand rather than glide because the glide was one of three things it does, and the name a user meets beside Reveal must say what the bars do rather than how. All three stay short and settle once. In code, the members that play them are ruled in BarMotionSeamTests’ ungated map, which may neither read the flag nor be added to without a ruling.

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 dwell, springs the view to that Space so the window is dropped into its live layout. The cross-process race that argues against spring-loading is narrowed to one place because KiwiDesk’s Spaces are virtual (a retile, not a WindowServer Space change). The load-bearing details:

  • The dragged window is exempt from all frame application in retile for the gesture’s life — the layout loop and stashInactive alike, via TilingEngine.dragExemptWindow, the same kind of pin as the existing !isFloating exemption — so the spring’s retile places the target’s OTHER windows but leaves the dragged one under the cursor; without it the retile stashes it or yanks it to its computed slot (a small dwindled BSP corner, say) 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 (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. Lazy membership at drop (to avoid stale state) leaves 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. 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 default. (#293 QA.) alignment places a lone bar along the shelf — start / center / end, values edge-relative (a left edge’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 every edge — never per-edge defaults. Once a bar’s items overflow and scroll, the three alignments deliberately collapse to the scroll offset; the control is not greyed for it (a static preview can’t know real overflow).

The Space Bar scrolls the whole bar when the Spaces overflow. (#385; a 100-Space case retires #293’s “spaces are a small, bounded set — clip, no scroll” assumption.) 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.

On KiwiShelf the arrows are gone (▸ One shelf holds both bars): the fading ends are the drag’s autoscroll zones, and every item’s hit frame stops at them, so the autoscroll and the drop-spring still govern disjoint zones and the argument above holds with a fade where it says arrow.

The bar cards share one canonical row order. (#374, re-cut by the #678 Phase 2 census render, by #1517 and by #2507.) The KiwiShelf card owns existence and placement: the Show master and its Each bar drawer, then Position and its Each bar drawer and Alignment — where the bars sit, coarse to fine — then Thickness and its Style and Margins drawers. Order and Space Bar minimum close the Style drawer: they change how the joined run draws — which bar leads, how much of a crowded edge the Space Bar keeps — and matter only where the bars share an edge. A master’s drawer hangs under it with no rule, since a rule marks a new topic. Each gate sits directly above what it gates. Colours live in the one KiwiShelf colour card — signature colours inline, the rest behind a shut disclosure, the Space Bar’s focused-window colour among them. A new bar row slots into this order — in the census order lists (BarsRowOrder), not a per-card invention — and a row both bars would read belongs on the shelf card, not twice.

Choices stay open; numbers go in one drawer per bar card (#2507, owner ruling 2026-10-21). Nothing hides rows by experience level, so every open row on Bars is in front of someone who just installed KiwiDesk, and the accordion is the page’s one density tool. So a bar card draws its choices open and its numbers in one drawer at its foot, under its last open choice — the Space Bar’s Fine-tuning — and a gated number may lead that drawer when its gate is that last open choice, as the front-app title length leads Fine-tuning under Show front app (and Durations follows Reveal on the shelf card). A number stays open only where the numbers in its drawer derive from it: Thickness, whose value drives Auto glyph size and Auto font size. No drawer holds one row, which is why the App Bar card’s title length stays open. This is a depth tier — tuning a user rarely touches — and not a grouping by widget type, which gui.md ▸ group by topic forbids: a choice that is set once and a number that is nudged once are different depths of the same topic.

Reveal and its Durations drawer follow the Show master’s Each bar drawer: how a hiding bar comes and goes sits beside what makes a bar hide, and the picker and the Reveal duration grey with it while no bar hides (owner ruling, 2026-10-21). The Expand duration does not: the expand plays with every bar on Always — a bar joining a fused shelf, the plate gliding — so it greys only while its switch is off. The Animate KiwiShelf switch has no row any more: the expand is the shelf’s own way of changing shape, which a user tunes by its length and has no reason to stop once Reduce Motion no longer does it for them, so on_expand stays a Lua knob and the Expand duration says so where it greys.

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.) With a Core default of 32, a laptop starter of 28 and a GUI band beginning at 30 — the shape this replaces — 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 Core’s own minimum-thickness constant 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 the group holding the field whole, 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.

On the shelf the floor is KiwiShelf.minThickness and the group holding thickness is kiwishelf, whose every field KiwiShelfParityTests holds encoded; the #1517 crossing moves a stored bar’s number onto the shelf rather than letting the default in, so the argument that no migration is owed carries over unchanged.

One exception narrows “every screen”: Glass, and so the first-run profile, thins the shelf to 32 pt when the MAIN screen’s full frame is shorter than 1000 pt (#1952, owner ruling 2026-10-04). It is a height rule rather than a class, because a 13” and a 16” MacBook are one class and only the short panels pay the chrome — 40 pt twice is over 8% of a 956 pt screen. It stays one number on every screen, decided by the main one like every value no layout owns, and is not a second default a user has to know about: it is the value the slider shows, and only a fresh seed or picking Glass again takes it, so no stored file moves and plugging in a screen moves no bar. A separate “Compact” look was refused for the reason in A bundled look is total: a first run shows Glass, and Glass is also the shape reset (StarterCompactShelfTests).

“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 Colors & glass 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. 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 colour key added to a bundled palette costs the mark once, not a migration. The same computed answer has a price when the colour surface grows. Suppose a bundled palette gains a colour key, such as #1679’s kiwishelf.border_color. A config saved before the upgrade still holds the shipped default there, so a palette it was wearing stops matching. The applied mark, and the Save sheet’s “already saved as” notice, both go quiet until that palette is applied again. A ConfigMigration could write the palette’s colour into the old config. It is refused, because it would have to guess which palette the user meant: a config carries colours, never a palette name. And that is exactly the stored note the computed mark exists to avoid. What the user loses is a checkmark, and nothing on screen changes. A key whose colour is invisible by default, like a border that ships off, makes that a price worth paying. A new key whose colour DRAWS by default is the case that could not pay it, and that change owes the question again rather than inheriting this answer (ColorPaletteMatchTests ▸ newKeyUnmarksAnOlderConfig).

A palette is a color recipe; a Profile owns the colors. (#375; since #1752 a look owns them, and a profile wears the shared look or its own — below.) 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 (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 (drag.ghost.fill_color vs drag.drop_zone.fill_color — bare wire keys collide between groups), 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. A name-check in the GUI apply path that forces border.glow on for Kiwi Neon (retracted in #578) is one-directional (a later sober palette, being color-only, cannot clear it, so glow sticks on) and a category error (picking a color swatch silently flips 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 — and a future palette that genuinely needs to recommend non-color settings takes a schema-level “recommended settings” sidecar, never a magic-name side-effect. The 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. The whole look as one artifact is the look’s, not a palette’s — A look is KiwiShelf’s styling and the colours it wears (#1684, #1752), below. 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. The pair must also separate under red-green vision loss (#470) — the separation clause of the palette-coherence heuristics above, which carries the numbers, the two green-primary siblings’ cool focused accent (#511), SpaceBarAccentSeparationTests’ catalog-wide sweep and its bundled-only scope; the shelf curates, Lua is open.

[Principle] A look is KiwiShelf’s styling and the colours it wears (#1684, #1752). The signal #375’s deferral waited for arrived, so a look is its own one-shot paint beside the palette, never folded into it. Palettes colour every surface (ring, drag visuals, marks, bars); a look is the shelf’s shape plus a complete copy of those colours, never a palette NAME. A name made the look a live link into another library: renaming, editing or deleting the palette changed or broke every look drawn in it, and a look is what the shared appearance of #1752 is made of, which has to be one whole thing. So a palette is a recipe painted INTO a look — the later pick wins — and a look saved from hand-tuned colours carries them without filing a palette. Every stored look carries every colour path, the shipped colours completing a sparse one, so applying it leaves no earlier colour behind (LookStoreTests). A look carries styling, never functionality (owner, 2026-09-27): a field is styling if it changes how the same items look or where they sit, and functionality if it changes which items exist, what they show or say, or what they do — so the App Bar’s grouping, Other Spaces and a bar’s on/off never join, and a preview draws the user’s own sections under the look. LookKeys is the register and classifies every field of the shelf, both bar styles, the focus border and the gaps (LookKeysCensusTests). Four writes reach past the shelf, all by ruling rather than by accident: the sheen, because the shelf’s border and indicator draw it and the focus ring shares the one value (the page’s caption and the Bars card’s pointer name it, so picking a look never flips a border setting unannounced — #578’s lesson); Liquid Glass, which a look writes on every surface the switch owns, since glass is one switch (#1307) and a shelf-only write would leave it disagreeing with itself; and the focus border’s shape and the global gaps (owner, 2026-09-28, #1739): a Tiler picture is a thin square ring and tight gaps, so a look that stopped at the shelf did not reproduce it. Width, corners, glow and glow size are how the ring looks; whether either ring draws, and whether it stacks over an app’s own chrome, stay functionality. The width and corners are every window stroke’s (#1742), so a look restyles the drag ghost and drop zone with the ring through the same writes border.set_width and border.set_corner_style make. Only the global gaps ride; a Space’s own override (Lua’s) stays. Gaps move windows, so a look is the first paint that rearranges them — once the draft is saved, as any gap edit does, which the page’s caption says, and the Gaps card’s pointer names the look as a writer of both. A bundled look is total: authored sparse, it is resolved over Glass — itself derived from the starter’s tuning for the connected screens, so it is what a first run shows and doubles as the shape reset (owner, 2026-09-28, #1739) — so each writes the whole register and nothing of the previous look leaks through (LookCatalogTests); a bundled look names its paired palette in the authored file and takes its colours at load, so a palette retune reaches the look (LookCatalogTests ▸ palettesExist). A click applies shape and colours to the draft. A tile whose shape is live in colours it does not own keeps its mark and says “Other colors” — re-colouring a look is a choice, not a fault, so never ⚠ — and a click on it brings the look’s own colours back (ShelfLookApplyTests ▸ matchReadsShapeThenColours). The one thing a click can lose is hand-tuned colours no saved palette reproduces, so for exactly those a “Keep previous colors” row offers them back, unticked, since a tick would make the click give something other than the tile it shows — computed from the draft, kept across a run of look clicks and gone once the colours move otherwise, so it never restores over the user’s own edits (KeepColorsOfferTests). A look card draws its focus ring with the sheen (owner, 2026-09-28) — a deliberate exception to the palette thumbnail’s rule of leaving a fact it cannot render undrawn at tile scale, paid for with a ring wide enough for the ramp to read. The names are ours; the reference lives only in the description, which says what the look does before what it resembles — “Bottom bar, like Windows 11” — because a new user does not know the bar vocabulary and the thumbnail cannot carry it (owner, 2026-09-28). Glass alone names itself KiwiDesk’s own look instead, since the default has no reference to borrow (owner, 2026-09-29). A look has one description, shared by the Settings card and the tour, Glass’s included (LookDescriptionsTests).

[Principle] Profiles share one look unless one keeps its own (#1752). Keeping several profiles on the same look meant applying it once per profile, and the copies drifted apart silently. So gui.json holds one shared look and each profile either follows it or keeps its own — one switch per profile over the whole look, never a control per setting, since a look is worth sharing as the picture it draws. The unit is everything a look sets, the bar edges and global gaps included: a profile that needs other geometry keeps its own look whole. A Space’s own gap exception stays with its profile, since it belongs to a Space rather than to the look. A Lua-owned config has no shared look: init.lua sets the look, so the switch greys (SharedLookCrossingTests ▸ luaOwnedCrossesNothing).

Absent means shared, so the crossing stamps rather than reads. Every profile from before is marked own (ProfileLookOwnMigrationTests); at the first apply of a stored profile its look seeds the shared one, and the election lets every profile already wearing it follow and moves a follower wearing another look to own, gui.json written before any profile (SharedLookCrossingTests, SharedLookLandTests ▸ electionMovesStrayFollowers), so nothing on screen moves. A profile born while no shared look exists is own for the same reason — following would make it wear, at the crossing, a look it never did. A restore takes the bundle’s shared look ahead of the gui.json write that stamps it; a bundle from before carries none, its step stamps its profiles own, and the crossing is owed again and elects among them, the shape #1741’s crossing takes.

Wearing the shared look keeps a per-layout App Bar indicator; only picking a look clears it (#2368). A pick says “draw this look’s indicator”, so it clears the per-layout ones that would hide it, for the profile whose settings it paints. Following the shared look is no pick. Clearing there made a follower’s stored per-layout indicator unreadable — the keep judgement reads a follower’s settings with the shared look worn over them — so a “Live, not saved” row for one survived every Save and Keep, and the next shared-look write stripped the value from the file. The leaves are one list, TilingSettings.layoutIndicatorLeaves, which the pick clears, the tour’s undo restores and the wear keeps (SettingsLiveFollowerTests).

Motion shares the look so it travels between profiles, but a bundled look and the tour’s paint say nothing about it: picking a picture never changes how fast things move (#2176). Animations were the one presentation family a profile kept apart, so turning an animation off meant repeating it on every profile; folding them into the look shares them through the switch above, where an app-wide value (#1741) would have lost the profile that wants animations off while presenting. LookKeys.motion is every field of AnimationSettings, read off its keys, and a look’s motion is read by the config’s own decoder, clamps included. A look the user saves and the shared look carry it; Glass extracts the styling alone (LookKeys.styling), so every bundled look, merged onto Glass, carries none, and the tour’s paint and its Revert strip it — otherwise each picture would reset tuned animations and a bundled tile would never read as applied over them (LookMotionTests, LookCatalogSeamTests ▸ glassReadsTheStarter). A shared look missing a motion path owes the election again. The next apply of a stored profile that FOLLOWS the look — a Standard saved through Apply included, since it is born following — or the next look write of one, settles it: the missing paths come from that profile, and every other follower that runs different animations keeps its own look, frozen as it ran, so no stored profile’s animations move. An apply of an own profile leaves the election owed and touches nothing: an own look is a user’s choice once followers exist, and electing from one would detach every follower (SharedLookMotionTests). A built-in layout has no file to keep its own animations in, so after the election it runs the shared look’s, as it already runs the shared styling. No format moves: the look’s style is a sparse map an older build reads past, keeping only the paths it knows, and a LookExport sidecar with motion imports there without it.

The shared look changes through one door, landSharedLook: gui.json first, then every follower’s file copy re-stamped, then the live screen’s look re-resolved (SharedLookLandTests). A follower’s copy is never read while it follows, but it is what an older build and a Lua-owned load read, so a copy left stale would resurface a look nobody chose. The re-resolve reaches the look alone and never re-applies the whole profile, which would reach a standing temporary layout (#1179). A write landed beside the door skips one of the two, which is why there is one. A follower’s look IS the shared one, so every write of it lands there — save_profile, a Save as, a Settings Save, the tour’s paint — or the next apply would paint the old shared look over it (SharedLookWriteTests). A new profile copies the switch of the profile it is saved from; a “Save copy” of a follower whose draft changed the look keeps it as its own, since a copy touches no global file.

The switch is the App Rules “Applies to” checklist, read the way App Rules’ is: on a following profile’s page, the profiles wearing the look the page shows. The edited profile’s own box is locked (owner, 2026-09-29): flipping it would change the checklist’s subject, the look this page shows, so a profile is moved from a page whose look it should wear. On a following profile’s page the other boxes follow or keep their own; on an own profile’s page they grey, since an own look is nobody else’s to join. A profile unticked keeps the look it wore before the Save’s edits, which is why every Save door writes the checklist before the profile or gui.json write it guards (SharedLookSeamTests ▸ checklistFirst), and its save-pill row says so. An own look carries no ⚠, since it is a choice rather than a divergence; the ticks are staged in the draft and written through saveLookReach (LookReachTests).

The App Bar is the dock; the Space Bar is the menu bar (owner, 2026-09-28, #1528). The starter splits them — Space Bar on top, App Bar at the bottom — and Glass carries the same split from the one StarterSetup.appBarEdge, so the default look places the bars where a new user first meets them; it is no longer the pure defaults, so it no longer resets to the type’s fused top shelf. Every other look places the bars as its reference does: split where the reference has a dock apart from its top bar, one bar where it has none — Taskbar’s bottom bar (Windows 11), Classic’s, Tiler’s and Pill’s top bar (Mac OS 9, Hyprland, Barik), Sakura’s split (iPadOS), each stated in the look rather than inherited from Glass (LookCatalogTests ▸ barsFollowTheReference). The cost, accepted: on a saved profile or preset’s fused top shelf, Glass is not ticked as applied, and picking it splits the bars. Presets never carry the split: a preset apply and the monitor-change fallback replace the live settings whole, so a preset that split would move an existing user’s App Bar unasked (StarterBarEdgeTests).

“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 is 8 pt, not 20, because the font is the real cost: monospaced readouts pad even narrow letters to digit width; 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).

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, which is what keeps the shrink from reading as a bug.

The background control names WHERE the background is drawn, not what the rectangle is called. (R6/#406, owner ruling 2026-07-25.) “Tab background” (App Bar) and “Item background” (Space Bar) are each 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_background → background_style, so both bars 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 is “Item gap” (box_gap → item_gap): it stays true under either style, because items have spacing in Plain too, they simply draw no box of their own.

A bar entry is an “item”, not a “tab”. (R6/#406, owner rulings 2026-07-25 and 2026-09-22.) 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 are item_color / active_item_color and the geometry item_gap, so item is the model noun — labels, help strings, captions, enum doc comments, the Lua reference and the user guide all use it. 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.

On the shelf the two bars share ONE plate in ONE Fill (▸ One shelf holds both bars): hug wraps the union of both sections’ runs and full spans the whole edge. The objection to one plate — that it would need a third fill kept in step with two others — fell away once every shared colour moved onto the shelf, since the shelf’s Fill is then the only one there is.

One Liquid Glass switch governs every KiwiDesk surface that draws it. (#1307, 2026-09-07; #1517.) Three surfaces — the two bars and the ⌃⌥K shortcuts panel (#1295) — under two independent per-bar settings and a constant is the shape one row on Looks & Animations replaces: it writes every glass leaf — the shelf’s, which both bars read, the panel’s, and (below) the drag visuals’ and the sticky mark’s — stored side by side in the profile.

The slow-boot notice (#1715) reads the panel’s leaf rather than storing one of its own.

So do the plates a Space switch slides (#2524).

So do the cue plates — the Space, Desktop and layout plates (#2569, owner ruling 2026-10-24) — so the one switch governs every glass KiwiDesk draws (PanelGlassTests).

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.

“Unbuildable” is true of a migration step, not of the move. #1741 moved three settings app-wide by an adoption at apply(profile:), which knows which profile is live and so can hold the election a step cannot (Spaces, profiles & config ownership). The Liquid Glass switch was not moved, and this paragraph is no longer a verdict on whether it could be: a proposal to make it app-wide argues its own case on that path and its trade — the first profile applied decides, every other profile’s value is abandoned.

The switch means ALL of them, and its ? carries what a boolean cannot. Owner ruling: off is a true statement whenever they are not all on, and a flip writes every leaf either way. That leaves some-on indistinguishable from none, 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.

A control’s own finish is not a surface the switch governs. (#1527, 2026-09-27.) The Settings slider knob turns clear glass while it is dragged, and the switch does not reach it: the switch is a preference about what KiwiDesk draws over the user’s desktop, and a Settings control behaving the way the system’s own macOS 26 controls do is not one of those surfaces. Tying the knob to it would give the switch a second meaning. Reduce transparency still stands the knob down to its white thumb, since that setting is the user’s about every surface.

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.

Tint KiwiHub and plates (#2569) gives KiwiHub and the plates the Fill too, and with it off the rule above stands (▸ the shortcuts panel entry).

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.

A Fill with no colour is one state, and it paints nothing on any path (#2569, owner ruling 2026-10-24, after an HTML preview; ui-designer concurred). An empty Fill, an unparseable one and one at 0 % opacity read alike, decided by one predicate, KiwiShelf.hasColour: a parseable Fill above zero alpha has a colour. GlassTint, the solid paths and Settings’ greys all read that predicate, and the solid paths reach it through NSColor(kiwiFill:) (NoColourFillTests). With glass the result is plain .clear glass with nothing pinned. Without glass — the switch off, Reduce transparency on, or below macOS 26 — there is no plate, and the items sit on the wallpaper. Before, an empty Fill on the solid path painted the system accent through NSColor(kiwiHex:)’s fallback, a colour nobody picked. The Settings way in is No Color on the Fill well’s menu. It sets the Fill’s alpha to 0 and keeps the hue, so raising the opacity brings the same colour back. A second “none” value beside the hex would have been a second state for one meaning.

Under Reduce transparency, no colour stays on the wallpaper (owner: “on the wallpaper”). This extends the sentence above that a fully transparent Fill stays absent, and it is a risk, recorded here rather than hidden. The setting asks for opaque backgrounds, and a user who picked No Color gets none: the bars’ fixed-hex ink sits on whatever the wallpaper is, with no plate and no measured floor beneath it. The reading floor cannot help, because it scales a Fill and there is none. The owner chose the user’s explicit “no plate” over the setting’s general request. A report of an unreadable bar under Reduce transparency with No Color is this trade showing, not a regression. The answer would be a ruling to draw an opaque ground there, not a quiet fallback colour.

The Settings row reads the same OS value and greys with its reason while the setting holds glass down (#1418), so the row and the surfaces cannot disagree — grey, don’t hide: the stored leaf is still the user’s and stays exactly as ruled above, and a row reading On over glass the machine is not drawing leaves a flip that changes nothing on screen with no in-app word why.

The drag visuals and the sticky mark keep their flat look under it, not made opaque (#1620, #1621). A marker exists to be seen through: an opaque drop zone would hide the window a drop swaps with, and an opaque ghost the slot the displaced window moves into. So under the setting each draws exactly what it draws with the finish off — the markers their border over the stored fill, the mark its .hudWindow badge — a disc in the colour, or the bare glyph on Automatic.

The panel’s key did not merge with the bars’: separate leaves, one row, through SettingKey.masterWrites. The bars share one leaf because a field both bars read is the shelf’s (▸ one shelf, above), which leaves no per-layout glass for the switch to explain; the panel is a different surface with its own set_shortcut_panel_liquid_glass, so the row ADDS Lua reach rather than capping it. icon_source and dim_factor left the per-layout chain the same way, both bars reading them alike.

The drag visuals and the sticky mark join that switch as leaves of their own, filled from its reading on upgrade. (#1620, #1621.) Each takes its own colour through GlassTint.apply, fading DOWNWARD, since neither sits on a screen edge the way the shelf does (#1622). A file written before them carries neither leaf, and absent they decode on — beside a shelf and panel the user may have set off, so the one row would open reading off with its ? saying the surfaces differ, on a plain upgrade. The step therefore fills both from the switch’s own reading over the leaves the file does carry, off where those disagree, as the panel’s leaf was filled from the bars’ (#1369).

The sheen is its own setting beside the Liquid Glass switch, never one of its surfaces. (#1644, owner rulings 2026-09-27, after the device sitting.) The switch governs “every KiwiDesk surface that draws glass” (#1307, above), and the sheen draws none: it is a lightness ramp on the coloured stroke — the focused ring, the shelf’s highlight and border, the drag borders — because a 2 pt glass stroke refracts nothing and carries no hue (#1297), so real glass there would weaken the one “this is active” signal. Being no glass, it takes none of glass’s gates: it draws with the switch off, under Reduce transparency, and before macOS 26, and a leaf of LiquidGlassAgreement would have made the switch read off with its ? whenever a user wanted glass without the sheen. The row stays on a Mac below macOS 26, where the switch above it is hidden.

One signed number, and no coupling to the switch. (Owner amendment 2026-09-27.) border.sheen is a strength in −1…1: a positive value lightens the top toward white, a negative one darkens it toward black, and 0 draws none — every surface’s flat path. The top moves by 0.9 × the strength, so the 0.5 default is the 0.45 lift the owner approved at the sitting and a full 1 stops short of white or black, where the stroke’s own hue would be gone. One number rather than a switch and an amount, because “off” is simply the middle of the range; one centre-origin slider, because a direction is part of the value. The earlier one-way coupling (switching glass on ticked the sheen) is gone: with a strength to choose, the switch would have had to pick one, and the two are independent looks. The master writes nothing to it (SheenCouplingTests).

Default 0.5, and existing files take it on upgrade. (Owner ruling 2026-09-27.) A file written before the key carries no sheen and decodes the default, so every existing user’s focus ring, bar highlight and border, and drag borders gain the sheen on upgrade, whether or not Liquid Glass is on. That visible change is the owner’s accepted cost rather than something a #1369 crossing was needed to prevent: absence never meant a sheen a user had turned off, since no release before this one drew one.

The flat band carries #578’s contrast; the ends may pass it. (Owner ruling 2026-09-27, over a cap.) The ramp’s middle band is the configured colour itself, untouched, on every surface — the focused ring, the bar highlight, the shelf border, the drag borders — so wherever that colour meets #578’s 3:1, the band does (BorderSheenFlatBandTests). Only the lifted top is free to go past it: the band runs to the bottom edge, which keeps the colour rather than darkening (owner 2026-09-27 — a shaded bottom read as a different colour on a short outline, and the lift alone carries the look). #578’s purpose is finding the focused window, and a ring whose flat band holds 3:1 on three sides stays findable when its top edge fades toward a white wallpaper. The cap this replaced held every stop at 3:1 and so took nearly all the lift from the default green ring, which is the one most people see; it traded the look the owner approved at the sitting for a guarantee the band already gives.

Both drag markers’ glass is thinned (owner, device 2026-09-25). The drop zone lies over the window a drop would swap with, which should stay readable through it, and the ghost matches it so the pair reads as one kind of marker; .clear is already the material’s lightest style, so the view’s own opacity is the one public strength left.

Glass drag markers sit at the normal level, ordered directly beneath the window being dragged and re-ordered there on every move; flat ones keep the floating level. At the floating level the glass would blur the window in hand whenever it crossed its home slot or hovered the target, which is exactly when the drop zone shows. Beneath it, both markers still sit above the windows below it, and the home slot is empty for the drag.

The Settings drag preview hosts Core’s marker, tinted glass and all. (#1645.) It draws the engine’s own DragMarkerView, so the picture is the drag rather than a re-drawing of it (#702). That does not breach #1295’s “this tree’s glass is untinted”: that ruling governs Settings chrome — a surface the window draws for itself — while a hosted engine view is a picture of a surface the product draws on screen, and a picture that dropped the tint would show a marker no drag draws.

Liquid Glass is an orthogonal finish toggle, not a third background_style. (#390; revised 2026-07-20.) A third BackgroundStyle case (material) beside boxed/plain — the rejected shape — rests on a toggle being ambiguous (“boxed + glass” = glass boxes or a glass strip under opaque boxes?), and on-device measurement (macOS 26.5.2) rules it out 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 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 read as a near-solid slab (#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). 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).

Amended: the Fill draws ON the glass, beneath its content, not behind it (#2569, owner rulings 2026-10-23/24). The tintColor finding above stands, and so does the one channel: the Fill still reaches glass only through GlassTint, capped. What moved is where the coloured view sits. It is now the glass’s contentView, over the material, and the items ride inside it — the place the hover fill already had. The backdrop behind the glass went in the same change, so the colour is never painted twice.

The cause is the key-window look below. Active glass is lighter and more opaque than the inactive glass the bars drew, and it covered a Fill sitting behind it. Measured on the owner’s desk with the look switchable at runtime, a red App Bar read as washed lavender, and a deeper red behind the glass changed nothing. No alpha within GlassTint.maxAlpha brought the colour back. Drawn on the material, the colour returned with the rim kept (owner: “very good”). The cost is some of the see-through blur, which is #1297’s slab risk. The owner accepted that trade for the rim, and the measurements under the Fill fade below bound it. Moving the Fill back behind the glass loses the colour under the rim, and dropping the rim to keep it there loses the look this amendment exists for. GlassFillOnTopTests holds the order.

The hidden shelf’s handles are the one exception: their Fill stays behind the glass (owner, 2026-10-24, device). With the Fill on top, the glass’s own edge — a dark line and a light lens band at the pill’s rounded ends, which the material draws itself — read as a border around the darker Fill (measured on the owner’s desk: screen 45, line 20, band 51–53, Fill 40, red channel). A handle’s panel never wears the key-window look, so the reason the Fill moved does not apply there, and behind the glass the band takes the Fill’s tint. Hovering still grows the pill and shows its highlight rim. GlassTint.applyBehind is that door and the handles its only caller (ShelfHandleFillBehindTests).

The bars, the overflow marks and their list, the hover peek and the plates wear the key-window rim. macOS draws glass in the key window with a bright top and bottom edge, which reads as more three-dimensional. The bars, the overflow marks and the hover peek never take the keyboard, so they drew the flatter inactive glass. Beside them, the overflow mark’s list, which is key while open (#2563), drew the rim. A panel that answers AppKit’s private _hasActiveAppearance with YES draws glass exactly as a key panel does, without taking the keyboard. On macOS 27 the edge went from 230 to 242, the same as a truly key panel, in AppKit and SwiftUI glass alike. So one install door covers the shelf, the marks, the peek and the plates. KiwiHub is truly key while open and needs none. The answer is added to KiwiDesk’s own panel classes and never to NSPanel, which every other panel shares. A macOS that stops asking leaves the added method uncalled, so the glass falls back to the inactive look and nothing breaks (os-private-apis.md owns the door). The bars, the marks, their list and the peek always wear the rim, since they are KiwiShelf’s look. The plates wear it only while they wear the Fill (▸ the shortcuts panel entry), whose off state is today’s look. The panels that answer are GlassActiveAppearance.classes, so the sticky mark, the drag ghost and drop zone, the hidden shelf’s handle and the boot notice keep the inactive look: a surface joins only with its own argument here.

On glass the Fill FADES: its capped alpha at the shelf’s screen edge, an eighth of that toward the windows. (#1622, owner rulings 2026-09-24/25.) A flat tint at GlassTint.maxAlpha is what made a glass shelf read as a coloured plate, so the fade is how a Fill tints glass rather than a second style beside it — a choice between the two would be a setting whose only job is to undo the glass. Both ends are relative to the Fill: the anchor is its alpha capped at maxAlpha, the floor GlassTint.floorShare of that, so a transparency choice still means how much colour the glass carries and a transparent Fill still draws none. The floor is never zero, so a hint of the colour runs the whole surface. It runs from the SHELF’s edge because the shelf has one Fill and one plate (#1517), and a boxed item fades the same way. For a dark Fill, legibility at the clear end rests on the #1308 pin below rather than on the alpha: the pin holds the whole glass dark (unmeasured at the clear end; #1622’s device check). For a light Fill nothing is pinned and the clear end carries an eighth of the tint, so the #1308 residue’s “the bright tint dominates” holds at the anchor edge only; at the clear end it is unmeasured. With the finish off, or Reduce transparency on (#1374), the Fill renders flat. No config migration is owed: no stored value or key changed and the Fill still means the colour, now at the anchor edge — a rendering change, not a meaning moved under a key (#1354) or a default flipped (#1369).

Amended: the fade is a setting, Fill fade (#2569, owner rulings 2026-10-24; kiwishelf.set_fill_fade). Its default is the fade above; its other end is one even colour. This explicitly overturns the sentence above that a fade-or-even choice “would be a setting whose only job is to undo the glass”. That held while the Fill sat behind the glass, where an even Fill at the cap was the coloured plate #1297 fought. With the Fill on the material, the rim and the material draw whatever the Fill, so an even Fill is a colour on glass, not glass undone. How much colour reaches the far end is then the user’s taste. The far end is never fully clear, as #1622 ruled. The anchor stays capped at maxAlpha. Fill fade governs KiwiShelf’s Fill and the hover drawn on it: the bars, a hovered bar item, the overflow marks and their list, the peek, and the panels the toggle tints. The drag ghost, the drop zone and the sticky mark have colours of their own and keep #1622’s fade. No crossing is owed, because an absent key decodes 100, which is the look every file already had.

Fill fade is a look key, so a bundled look sets its own (owner, 2026-10-24, on ui-designer’s advice). Pill sets 0: its reference is an even dark capsule, and a boxed capsule that fades reads as unevenly lit. Sakura sets 50: its reference is an even frosted dock, and its 44 pt bar leaves room for a gentle gradient. Glass, the starter look, keeps the default 100. Taskbar, Classic and Tiler draw no glass, so a fade there would change nothing and they set none. A lower fade only raises the far end’s alpha toward the Fill the inks were chosen against, so the contrast suites’ fade-100 case stays the worst one.

A hovered bar item’s fill fades with the Fill (#2645, owner ruling 2026-10-26, after comparing flat and faded hover live). On glass the hover colour fades along the same edge by the same Fill fade, capped and floored as the Fill is. Fill fade so reaches one colour that is not the Fill, because the hover draws on the Fill; a colour drawn on no Fill keeps its own fade. The hovered item reads as one with the pill under it rather than a flat patch on a faded plate; under Boxed, a flat hover made the box look as if it stopped fading beneath the pointer. A ui-designer read preferred a flat hover as pointer feedback, one even pill as macOS draws one; the owner judged the faded hover the better read. The overflow count at a run’s end follows too (owner, 2026-10-26): it sits on the faded plate, so its hover chip fades as an item’s does. A solid shelf keeps its flat hover, its Fill not fading, and Reduce transparency returns the flat hover with the rest of the glass. Each layer takes the cap, not the stack: under Boxed the hover draws over the box’s own faded tint, as the flat hover did, and that layered look is the one the owner compared and chose.

KiwiHub’s selection fades with its Fill (#2721, owner, 2026-10-28, after comparing solid and faded fills on a live prototype and the device). Under Tint KiwiHub and plates, every selection and hover fill — the scope switch’s pill, hover and track, the Windows rows, the View side’s chips and layout tiles — fades top to bottom along the panel’s Fill. A solid pill read as a flat patch on faded glass, the #2645 case one surface over. The fills fade deeper than the panel, to 0.4 of their top at Fill fade 100 (KiwiShelf.highlightFloorShare, ui-designer’s pick): the panel’s floor keeps the panel’s inks legible, while a fill carries its own ink, and at the panel’s 0.46–0.73 the fade was barely visible on the device. Matching the panel was weighed and refused for that reason, and the unfloored 1/8 for leaving a tile a band along its top. The label sits mid-height, where the fill keeps about 0.7, and the selected and hovered inks are measured at the far end on every bundled palette (HighlightFadeContrastTests). Where 0.4 would sink a fill’s own ink — under Increase Contrast, whose floor is stricter — the share rises to the point that holds it, over both ends of the panel (highlightFadeShare). The footer’s buttons (Edit in Settings…, Keep) fade too (#2725, owner, 2026-10-28): KiwiDesk already draws them as button chips (#2377), and a flat chip read as the one patch on faded glass. Their rest and hover fills take the same share, while the chip’s 1 pt edge stays flat: it is the button’s outline, and faded it would leave the chip open along its bottom. Off the toggle, and in Settings, the fills stay flat. Residue: the resting chip’s label on its faint neutral wash is measured at neither end; the fade moves it toward the bare Fill, whose inks are measured.

A bar badge keeps its disc opaque (#2722, owner, 2026-10-28, on the device). The group count, the Space Bar’s +n and its sticky, floating and pocket badges dimmed as a whole, so on a faded plate the disc read as a grey smudge. The dim ladder now reaches the mark alone, and every badge shares one face (BarBadgeFace): an opaque disc with a 1 pt rim that parts it from the glass: white at 0.35 on a dark shelf Fill, black at 0.25 on a light one, dimmed with the mark; with no Fill the glass follows the Appearance pick, and so does the rim. A 0.5 pt rim picked by the disc’s own brightness vanished on the device, a black rim on a light disc disappearing into dark glass (ui-designer). The +n disc is 0.7 of its cell, smaller than the 0.8 it had, which read too heavy beside the icons. Glass and a fade were weighed and refused: ui-designer pointed to Apple’s solid badges on glass, and at a 14 pt disc a fade reads as a smudge, not a fade.

Reading surfaces fade above a floor, and the clear end no longer rests on the #1308 pin. This replaces the two “unmeasured” clear-end sentences above. With the Fill on top and Fill fade at 100, the text on a reading surface reaches the faint end. With no floor, the worst bundled-palette ink fell to about 1.0:1 over a white wallpaper. That covers titles, the peek header and a hovered row, and KiwiHub’s primary and secondary labels (ColorVision, the Fill composited over pure white and pure black). The owner judged it on real .clear glass in a lab, over all ten bundled palettes, a KiwiHub mock, and white and black grounds. 1.4:1 was okay, 1.5:1 readable everywhere, and 1.6:1 a bit better; ui-designer concurred. So the hover peek, the overflow mark’s list, KiwiHub and the cue plates follow Fill fade, but their far end stops at the share that keeps their worst ink at 1.5:1 over white or black (KiwiShelf.readingInkFloor). That share is computed per Fill from ColorVision and never stored. Under macOS Increase Contrast the floor is the bars’ own 2.2:1 (KiwiShelf.idleInkFloor), applied live, and the fade stays on. The floor is silent: no help text names it, because it is a net under the slider, not a second control. The model composites over a flat wallpaper and leaves out the glass’s milky layer, so it reads the screen pessimistically. Retune the floor on real glass over white and black, never from the model alone (PeekInkContrastTests, GlassFillFadeTests). The bars, the marks and the hidden shelf’s handle follow the slider freely. They carry glanceable glyphs, not lines read top to bottom, and their ink at the anchor edge is the cap’s job above. A Fill with no colour leaves nothing to floor: the peek and the list then draw on plain .clear glass (▸ Reduce transparency, A Fill with no colour is one state).

The finish is ON by default on every surface (owner ruling 2026-09-10: 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 could be the dark one — in that measurement the App Bar usually was, since it sat on the bottom edge, which 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. That the unpinned glass carries a Dark pick was measured on macOS 27.0.1 (#2543): a clear Fill’s plate read 33 luminance under Dark and 89 under Light over the same backdrop, Dark → Light → Dark, while a light Fill read 208 under both, its tint holding the scheme. Precedence, since two sanctioned writers 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.

The light limit is AppKit’s, and SwiftUI glass pins light (#2569, owner ruling 2026-10-24). “Only dark can be pinned” is a fact about NSGlassEffectView. SwiftUI glass takes a forced light scheme. So while Tint KiwiHub and plates is on, a light Fill pins KiwiHub and the cue plates light, the mirror of the dark pin, and their text is dark in every Appearance mode. The owner ruled this after a real-glass lab of the Clean Light Fill under a Dark pick, over white and dark grounds, where following the pick put white text on a near-white panel; ui-designer concurred. GlassTint.pin decides both variants at the one wantsLightInk threshold, and GlassPaint carries the verdict to SwiftUI (GlassPaintTests). The bars, the peek and the slide plates are AppKit glass, so they keep following the pick for a light Fill, the slide plates while they wear it included. A Fill with no colour, and the toggle off, follow the pick everywhere.

Background style and active indicator are orthogonal. (#228.) A coupled style enum (pills / segments / underline) conflates two orthogonal concerns, the per-item box rendering and the active-item marking; they are split 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 glyph style” — offers app_image (the app’s own icon) and app_font (a monochrome symbol). Decisions folded in (ui-designer consult and owner direction, 2026-07-17/18):

  • 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.
  • Symbols follow the bar’s state text colors (normal / active / hover) — one color system with the labels. Apps without a symbol keep their native image.
  • A synthesized Tinted mode is rejected (with its tint_appearance sub-setting): the system-wide Icon & widget style already covers the want for tinted app 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 is read from the font itself — the APPM data map upstream embeds in the TTF’s meta table, decoded directly in Swift and cached, which keeps bar rendering independent of the user’s Lua VM. Upstream’s icon_map.json / .lua / .sh snapshots are not vendored: they are copies of the same table that can go stale against the font they describe, while the embedded one cannot.
  • Vendored, not user-supplied: the TTF, carrying its own name table, ships 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.
  • The shortcuts panel follows the GLOBAL glyph style: with app_font active its Apps section leads with the same ligatures. The panel spans all layouts, so a Lua-only per-layout icon_source override deliberately does not steer it.

On KiwiShelf the style is the shelf’s (kiwishelf.icon_source) and no layout overrides it (▸ One shelf holds both bars), so the per-layout depth in the first bullet and the chip’s count of it are gone; the one row sits in the KiwiShelf card’s Style drawer.

[Principle] The bar font’s weight is stored as asked and resolved when drawn (#1681). kiwishelf.font_weight keeps the number the user chose, 100–900, whatever family is set, and BarFont decides at render time what that family can draw: a variable family draws the exact value on its wght axis, one without an axis draws its nearest installed face, and a family that is not installed draws System. Resolving on write instead would round 540 to Menlo’s Regular the moment Menlo was picked, so a switch to Menlo and back would quietly lose the choice; kept as asked, the family is the only thing that changed. The same holds for the family itself: a missing one is kept by name and reported, so installing it brings the look back with no edit.

The system face is drawn through NSFont.systemFont(ofSize:weight:) only at one of its named weights. Between them that call snaps to the nearest named weight (measured 2026-09-26, #1681), which would make every in-between slider position a lie; the wght axis draws the value itself. At the default 400 the call is the one the bars always made, so a profile without the keys draws its text — counts included — exactly as it was saved. A chosen family draws its counts with digits of one width, so a badge keeps its size as its count changes; the system pair keeps its own figures, since widening a count on an untouched profile would break that promise.

The weight row keeps its slider for every family and greys it where the family has no wght axis on the 100–900 scale (#1859): a family of fixed faces draws the same face across a run of slider positions, so a live slider there moves and changes nothing. The chips stay live and the greyed slider sits on the face drawn, while the stored weight is still the one asked for. Removing the slider everywhere was weighed and refused, since it would take the in-between weights of a variable family off the GUI; hiding it per family would make the row change kind with the family.

[Principle]

A Desktop is its stamp, not its number (#1147). Mission Control’s Desktop number is a position — index + 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.

[Principle]

A binding names its profile, not the profile’s name (#2324). Keyed by name, a Desktop binding re-points silently: renaming the profile drops it, and a new profile saved under the old name inherits a binding made for something else — the stamp’s failure one hop over. So a profile file keeps an id and a binding names that; the name rides along only as the label a row shows while the profile is gone. A profile written before ids takes an id derived from its name, identical on every Mac, so a shared folder upgraded on two Macs agrees and a binding stored by name still finds it — which is why that derivation must never change. A binding whose profile is gone reads missing, never couldn’t load: one is fixed by re-pointing the row, the other by repairing a file, and one word for both sends the user to the wrong fix. A binding declared in init.lua keeps naming its profile by name, since that file re-declares it on every load.

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 count sentence names 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 profile row and a preset card COUNT their screens and Spaces; they draw neither (#1624). An outline per screen carrying the layout its first Space opens in reads as a layout preview, and hides the thumbnail’s one job: how many screens. Two counters — display and the Spaces page’s own squares.below.rectangle — answer that at any count, in a column that does not reserve room for the widest profile. Per-screen opening modes belong to the preset’s Layouts sheet (#859) and, once a profile loads, the Spaces page: the thumbnail identifies, it never describes. The counts’ sentence is the counters’ tooltip and the name’s VoiceOver value, so they are never symbol-only; a fact that changes what loads (the dormant line) stays in the row, and only the counts sit behind hover. One glyph means one count: once display beside a number is the screen count, the collapsed setups chip counts in words (“setups: 3”).

[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 otherwise discoverable only by watching profiles load and guessing. The page states the rule and the card 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. Asking ProfileManager.match alone is only the display half, because a Desktop binding outranks matching — so on any machine with a bound Desktop such a card names one profile while another is 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, the binding rows were greyed rather than live-under-a-warning (#678): leaving the menus live lets a user configure bindings that cannot do what the row says. #888 removed the premise rather than the ruling, giving the trigger a definition (the main screen’s Desktop) that holds in every display mode, so the grey, its inline warning and its Desktop & Dock button retired together; the stored-profile grey — a different premise — is ruled on its own terms at #1392 below. What survives, because it never depended on the instance: a control the OS genuinely makes meaningless is 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 without it the two 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 disagree about the result — the boot and Desktop-switch door marking the misfit clean, the monitor-change door letting it stand dirty — so a restart shows a Screens drift row that a Desktop switch does 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 did not give — one Desktop loading a different profile per screen count — is the next entry (#1436). The Desktops row keeps a standing-aside binding visible, in keeping with don’t hide: the binding is not broken, it is waiting, and the row narrates Core’s verdict rather than re-deriving it, as the count group’s header (#1436). 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 Desktop holds one profile per screen count for all screen setups, and the card groups by count rather than offering a count control (#1436). The feature the #1394 ruling named as its own: a laptop that is sometimes docked wants Desktop 2 to load Laptop alone and Dual when the external is connected. The record lists its profiles and stores no count beside them — each profile carries its own for life, and upsert refuses a set of another count — so the gate’s one judgement picks the entry saved for the connected screens, and a second bind_profile_to_desktop with a profile of another count adds beside the first while one of the same count replaces it. A profile not saved yet is a class of its own there: two unsaved names cannot be told apart, so the newer replaces the older. A screen-count control with one picker per Desktop for the chosen count was refused (ui-designer, 2026-09-16): a control adds a mode whose selection can disagree with what is firing, hides the rest of the table, and — the count set being user-generated — would have to be a menu, the worse control for “which counts do I have profiles for”. The Presets card on the same pane answers the same question without one: the connected count leads under For your N screens, the other counts follow under their own headers, and a single-count user sees the card exactly as before, headerless. Two consequences follow. The group header carries the fact a for N screen(s) badge would, and the card’s ? states the per-count rule with no “the others wait” tail (owner, 2026-09-16) — and a bound name whose profile no reading can count sits in a last Couldn’t load group, offering only None, since it belongs to no count and a picker that cannot say which count it edits must not pretend to. A connected count no profile is saved for draws no rows: a None-only picker is a dead control, the affordance-for-a-channel-that-does-not-exist case, so a caption takes its place and the other counts follow with headers.

[Principle]

A Desktop binding for all screen setups outranks the profile that holds the connected setup, and a binding scoped to that setup outranks both (#1609). Once a screen setup has one holder (#1530) and a Desktop picks a profile per count (#1436), the two answer the same question on a bound Desktop and one has to rank. The ladder, first that applies: a profile bound to the main screen’s Desktop for the connected setup, one bound there for all setups, the profile that holds the setup, the count’s default, a built-in layout. Putting the holder above the all-setups binding was the other candidate, and it was refused on what it does to files that already exist. A binding written before scopes names no setup, so it can only mean all of them, and after #1530 nearly every setup a user has saved on has a holder — so on update every existing binding would stop firing wherever its author had ever saved a profile, silently, with nothing on screen to connect the change to the update. No migration could undo that faithfully: nothing in a stored binding records which setups its author meant it to win on, so any rewrite is a guess, and either guess breaks someone. With the binding above the holder, an unscoped binding means exactly what it meant, nothing moves, and no migration is owed. A user who wants the holder to win on one setup says so by giving that setup its own row, which ranks first. The price is that a setup’s badge on the Profiles page can sit on a profile that does not load on a bound Desktop, so the ladder is stated once, numbered, in the ? of the card that says what loads — at the top of the page, beside the status line naming the rung that answered, the flow stating a fact and the ? the concept — and the Desktop’s own row names the holder it loads over, rather than leaving the badge to mislead by omission.

This does not reopen #1394’s refusal of screen identity. That refusal was of the Desktop’s OWN screen as a binding key, which the Desktop stamp already separates. A scope names the whole connected set, by the sorted fingerprints a monitor set already carries, so a binding and a holder name a setup the same way.

An entry is the pair (profile, scope), and only a pair that overlaps replaces. One profile may be bound for several setups on one Desktop, and a Desktop may hold, per count, one profile for all setups and one per named setup: those never compete, since the gate ranks a scoped entry above the all-setups one and two different setups are never connected at once. Two entries of one count and one scope are the only pair that would claim the same screens, so that pair, and no other, replaces. A bare name stays the stored shape of an all-setups entry, so every existing file decodes unchanged; the format bump is only the refusal an older KiwiDesk owes a file whose object entries it cannot read.

The Desktops card reads top-down in the ladder’s order. Under a Desktop come its screen-setup rows, then Add a screen setup, then All other screen setups with 1 screen — the first row that matches the connected screens is the one that loads, the same order the ? numbers, so the layout teaches the rule rather than needing a caption to. The scope is written on every Desktop even with nothing added — All screen setups with 1 screen beside its picker, one known setup or many — because a bare picker binds every setup of its count without saying so, and a first binding made undocked then silently fires docked: the over-reach the scope exists to prevent (owner, 2026-09-23). The label names the screen count because that is where the binding stops; a count of KNOWN setups would claim it stops short of setups not yet connected. Add a screen setup is offered only where a second setup is known, since with one there is nothing to add. Adding a setup fills in the profile that holds it, where one does: that is the likeliest wish, and it is the fix for the row that says the holder is being loaded over. A scope menu under each picker is refused: the scope is then a value to open rather than a place in the list, and in a clickable mock the owner could not tell which row loaded (the issue’s ruling amendment, 2026-09-23). A disclosure per Desktop is refused: it adds a click in front of the only control a row has, and collapsed it hides the rows that decide what loads, which is the one thing the card exists to show.

[Rationale]

The Desktop binding rows are live under every edit target (#1392). A binding says which profile a Desktop loads, which is a fact about no one profile — the table is global — so the profile the Settings window happens to be editing does not change what a row means, and greying the rows behind switch to Live says nothing a user can act on except a detour. The grey protects no ruling: a stored-profile Save that writes only the profile’s own file drops a live row’s edit, which is a missing write, not a reason to withhold the row — a stored-profile Save files an edited binding table into gui.json through one Core door (rewriteSidecarBindings), and only for the rows that changed. The one state that still greys them is a stored profile of a config the GUI does not manage: that Save has no sidecar to file into, or one nothing reads, so a live row there would offer an edit that goes nowhere, and the rows dim with the cause. Live is never greyed, since its Save creates the sidecar. Moving the binding into the profile was refused on the standing rule: a profile owns tiling plus sparse overrides, never anything that routes or selects the profile itself.

[Principle]

A monitor set belongs to one profile: the one most recently stored or loaded with it (#1530). Matching picks the first profile, alphabetically, that holds the connected monitor set, so of two profiles holding the same one the same one always wins, whichever you saved or loaded last. The fix is one owner, not a better tie-break. A save of the live arrangement (when no other profile owns the set), a create, a load of a profile saved for as many screens, and the Profiles page’s + each hand the set to that profile and take it from every other of the same screen count, without asking. The page shows each profile’s screen setups, so you can see where one went. Resolving by a recency stamp was refused, because it hides the state that page exists to show. The consequences:

  • Boot, a monitor change and a Desktop binding never take a set. Two hand-edited files holding the same one still resolve as before, and the next save or load settles it, so a start-up cannot rewrite files you did not touch. A binding picks a profile and owns no screens (#1436), so a bound profile that lost the set loads dirty.
  • Except once, at the update (owner, 2026-09-23). The first start past the one-owner format, and every restore of a backup, settles each set several profiles held onto the one that loads it today — the alphabetically first — and takes it from the rest. Every screen setup a profile holds loads the profile it loaded before; only entries that could never win go, so the Profiles page is truthful from its first showing. The one visible move is a default: a holder left dormant hands its flag to the profile that kept the set, which then answers screen setups no profile holds, and the log says so. The crossing is decided when the profile store opens, before any reader can stamp a file, and counts only files that decode as profiles. Leaving them for the next save or load was weighed and refused: a user who never saves on that screen setup would keep a page contradicting its own rule indefinitely.
  • A set changing hands keeps its pins. It arrives with the pins its previous owner held for the Spaces the new owner declares, so a round trip keeps the pins of Spaces both declare; picked onto the loaded profile, the pins take effect at once.
  • A profile that loses its last set goes dormant rather than being deleted. It keeps its screen count (the file carries monitor_count), is never picked by the exact-match or count-default rungs, still loads by hand or through a Desktop binding, and takes a set back on its next load. This amends #36’s ruling that a profile with zero valid monitor sets is invalid. A count’s default must be a profile that can load: a dormant default hands its flag to the profile that took its set, make default refuses a dormant profile, and a deleted default’s heir is never one. A copy made with Save as new profile… while editing a stored profile starts dormant too, because copying the source’s sets would give each of them two owners.
  • Taking the connected set away from the loaded profile marks it unsaved. No apply runs on a pick, so the pick re-judges the #36 fit itself.
  • A save never takes a set another profile owns. The loaded profile only reaches such screens through a Desktop binding or a set moved away by hand, and in both the owner keeps it; a load and a create still claim. So a binding load shows no “Screens” unsaved change, and its Save cannot strip the owner.
  • save_profile and load_profile name what they took, and why. A scripted save that changes another file must not do it silently.

[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 Settings Save or save_profile 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.

[Principle]

An unplugged screen’s Spaces are held, not forwarded (#1507). A monitor change that switches profile makes the incoming profile’s Space set the authority, and its prune forwarded every window of a Space it did not declare into its fallback Space: a second screen’s arrangement collapsed into one Space on the laptop. Plugging back in could not undo that where the docked arrangement is a composed Standard — as it is on a Mac whose saved profiles are all single-screen — because a Standard has no file for #1230’s record to live in. And nothing needed destroying: a Space whose screen is absent already resolves onto a remaining one, and only the prune dropped it. So a departing Space that still holds windows — live, or on another Desktop — is carried as a held Space, its origin (its name there, the screen, its icon there, and the arrangement it left) kept beside it. An empty one is dropped as before: holding nothing would only take a number and a chord.

A Space is held for the screen it LIVED on, not for its pin. Its pin names that screen when it has one; a Main-role or auto-placed Space has none, and the first report of the screen change re-resolves it onto the remaining screen before the profile is chosen, so the screen it was on is recorded at that report and read by the hold. Keying on the pin alone would forward exactly the Spaces a user never pinned.

A name collision renumbers, past every live number. The incoming profile’s own Space keeps the name — it is that profile’s declared arrangement — and a name is a Space’s identity (Spaces, profiles & config ownership), so the two cannot share it. The held one takes the next number past the highest live one, a Space the prune is about to drop included, since numbering into it would merge; so live 1–5 holding another screen’s 3, 4, 5 numbers them 6, 7, 8, and the #485 digit top-up gives each a chord in order where KiwiDesk manages the config. The same holds later: a held id is never a declared one, so an arrangement that applies declaring a held Space’s number moves the held Space on first, unless it is going home under that very name. One rule for every name: a colliding Mail becomes a number too. A suffix grammar (3', Mail (DELL)) is refused because it mints names nobody types into focus_space, and every reader of a name would have to learn it. The namesake adopting the windows is refused because it merges two arrangements into one Space, the merge #1230 ruled out; the resident keeping the name with the held Space reachable by a bar click alone is refused because it leaves a Space with no shortcut of its own.

The held Spaces keep their order (#1664). Where only some names collide, a numbered held Space keeps its own name only while it numbers above every held Space before it, so a screen’s 3, 4 beside a declared 3 become 5, 6 rather than the old 3 renumbered to 5 sitting after the old 4, which kept its name. A named Space keeps its name wherever it falls, and the batch sits in the bar in the order the screen had it, behind the resident Spaces. Keeping more numbers is refused because the bar and the digit chords would then read the screen backwards. A later reclaim renumbers only the Space the arrangement claims and keeps the bar’s order, so its numbers may then not ascend. Renumbering the rest there too is refused: the old number is pruned at a hold but not at a reclaim, and dropping a Space is forwardWindows’ alone (#1177), so each would need a live rename across every store keyed by a Space — for a case only a later arrangement declaring a held number reaches.

Accepted: the chord outlives the Space. The top-up writes the renumbered Space’s digit chord into gui.json, and nothing takes it back when the hold ends. The ruling asks for the top-up, since a held Space without a chord is reachable only by a click; the chord left behind names a Space that may no longer exist, and pressing it then makes an empty Space of that number, as focus_space does for any Space it does not find.

A drawn marker, never a name change. Identity stays the bare name, so focus_space 6 and the digit chord work unchanged. What the user needs — this Space is held, from which saved profile and which screen, what it was called there, and that nothing saves it — rides a two-screen marker (display.2) drawn after the identifier in its own ink, an outline at regular weight so it reads as an object rather than a mark to decode, and the sentence the item announces to VoiceOver. There is no tooltip (owner ruling, 2026-09-25): macOS shows a view’s tooltip only while its app is frontmost, and KiwiDesk is a background app, so a tooltip would promise a sentence nobody sees. The badge is therefore a held Space’s whole visible affordance, and space_bar.set_sticky_badge(false), which hides the window-state badges, does not hide it.

Invisible to every arrangement write. A held 6 written into the laptop-only profile would become a declared Space there, never retiring, and sit beside a new held 7 at the next undock with nobody told. The same holds for Keep and save_profile, for its pin, for the Settings draft and Save, and for the gui.json space list, whose Spaces cold boot seeds back into live — and for #1230’s record, which is the incoming profile’s own arrangement and not the one the held Space came from. A Settings Save therefore neither persists a held Space nor prunes it for being unlisted; Settings ▸ Spaces draws it as a view-only row after the others, which is showing, not saving. This is the opposite of the #1175 heal’s seed, which every save captures: a seed is the screen’s own Space for the user to adopt, while a held Space belongs to an arrangement that is coming back. A temporary Space (#1790) is kept out of the same writes for the opposite reason — it belongs to no arrangement yet — and save_profile is the one write that takes it. The marker and its “not saved” sentence stand in for a promote verb on a held Space, which can come later if it is asked for.

The incoming profile’s record outranks the hold (#1728, reversed on #1790). The switch that holds a Space also runs #1230’s restore for the arrangement coming in, and that arrangement has usually seen the held Space’s windows before, in Spaces of its own. A window it remembers goes there, live or away and remembered there, and the held Space keeps only the windows it has never seen; one left empty retires. A remembered window sat in that Space because the user put it there the last time that setup was live, so the restore returns their own placement rather than stuffing another screen’s windows into it. #1728 first ruled the other way, because a replug restored the docked layout from the docked profile’s record alone and that record did not survive a KiwiDesk restart, so a restart while undocked lost it. Once #1828 kept every profile’s record across a restart, and #1832 gave a composed Standard a record of its own, the replug restores from memory like any switch, and one rule serves every switch. The record stores membership rather than order (#1387), so a row that returns from memory may come back in another order than a held Space would have kept.

It goes home only into the arrangement it left. A held Space returns when its screen is connected, the arrangement applying is the one it left, and that arrangement declares its origin name; it then takes everything inside — windows opened while it was held included — and the returning arrangement’s mode. Another profile that happens to declare the same name is a different arrangement whose 3 is a different Space, and filing the held windows into it is the name merge #1230 refused. A reconnect that keeps the live profile runs no apply, so it returns its held Spaces on its own; one that applies nothing because a hand-written config only takes placement from the Standard returns nothing. Where a held Space does not return, it stays held, pinned back to its own screen: it is still not that arrangement’s to save.

Every switch holds what the incoming arrangement does not name (#1790). A switch is any apply that changes the live arrangement: a Load of another profile, a Desktop binding, a monitor change, a composed Standard taking over. A live Space the incoming profile or Standard does not declare by name, and that still holds windows, is held for the arrangement it leaves — whether its screen left or stayed, and a temporary Space included, which comes back temporary. An empty one is dropped. The unplug was only the first case found of a general loss: a user trying another profile and coming back meets it too, since a switch that forwards spreads the departing arrangement’s Spaces into the incoming one’s fallback, and a Space no arrangement declares — a temporary one above all — has nothing to come back as, so its windows stay spread for good. What caused the switch — a screen, a Desktop, a menu pick — changes nothing about what it does to the Spaces, so the hold does not ask. A switch away from a hand-written config with no profile or Standard live has no arrangement to hold for, and holds only a gone screen’s Spaces as before.

It ends by going home, by emptying, or by resetting every setting. It retires the moment nothing is left in it; a window on another Desktop or of a hidden app still counts, since it comes back to that Space. A Load does not end it: trying an arrangement is the case the hold exists for, and forgetting holds there would re-open the merge the rule above closes. A Load’s prune still drops the empty Spaces. delete_space forwards a held Space’s windows to the fallback, which empties it and ends the hold; the Space Bar’s Delete is offered only for a Space that holds nothing, so it never reaches a held one. A config reload is not such an act: it redeclares nothing about a held Space, so it leaves its mode alone.

A window moved out of a held Space is #1230’s to place on replug. Where the returning arrangement is a saved profile, its record still files that window under its own Space, so the restore moves it back: each profile is its own arrangement of the windows, and a move made while another profile was live is that profile’s. Where it is a composed Standard there is no record, and the window stays where the user put it. No ledger is added to make the saved case stick — the model decides it, and one move after the replug overrides it. What is inside the held Space goes back in both cases.

A composed Standard keeps a record too (#1829). It is an arrangement of the windows like a saved profile — the one a Mac whose saved profiles are all single-screen docks into — so it files its partitioning as it goes inactive and gets it back when it returns, and a window moved out of a held Space goes back there as well. This retires two statements above: that a Standard has no file for the record to live in — it needs none, since the record lives in memory and rides the session snapshot (#1802) — and the Standard case of the held-window rule. It is keyed by ARRANGEMENT, never by name, because the docked Starter Standard and the saved Starter profile share one, and a name key would read the switch between them as no switch at all. One key serves a Standard at every screen count, as one name serves a saved profile at every monitor set, so the record holds the composition last live under that name and the restore fills only the Spaces the returning one declares. Auto-saving a profile for the screen set instead was refused: a monitor change never claims a monitor set (#1530), and the Standard stays transient so it can recompose.

It survives a restart, a crash included (#1646). Taking the laptop away for a day spans an update, a crash or a quit more often than not, and a hold that ended there sent its windows into the Space in front of you with nothing going home on reconnect. So the held Spaces ride EVERY session snapshot — a quit’s, the crash autosave and an in-place restart’s — and not the in-place payload alone: a hold is part of the arrangement, like membership, and not session sizing, whose reset at a relaunch users rely on (An in-place restart gathers nothing). The issue asked for the record to be cleared in crash recovery, as the other session stores were; since #930 crash recovery RESTORES those stores from the autosave, so clearing the hold alone would bring its windows back into a Space that no longer knew it was held — badgeless, and captured as a declared Space by the next save_profile, the leak ruling 5 exists to stop. The crash therefore restores it too (owner, 2026-09-27).

Boot re-creates it, one of two Spaces a restore creates. The replay never creates a Space (#633), because a resurrected Space used to be written back into gui.json; a held Space is declared by no config, so without an exception its windows would be filed nowhere. The exception is safe for the reason the hold is: no arrangement write captures a held Space. It runs ahead of the replay, so its windows are filed into it before any pass draws. A held number the booting arrangement declares, a live Space takes or the snapshot records for another Space is renumbered past all of them, in the batch’s order — a profile loaded while KiwiDesk was down may declare it, as a reclaim would find, and a recorded one would merge two records’ windows — except for a Space about to go home under that very name, which is filed straight into its home and keeps that Space’s own mode. One whose screen is connected at boot then goes home by the reconnect’s rule.

It ends by the same rule as ever, judged once the desk is known. A held Space’s record rides its own Space record, with the windows it holds that are not members — a hidden app’s, one on another Desktop — since the membership list cannot name them. At boot every recorded hold comes back, and each window it holds that the scan did not find is remembered there, as the replay remembers any late window; the retire (#1507 ruling 4) then keeps it while any of them may return. Whether one may is the WindowServer’s to say, window by window, and only after the away seed: a window it hosts on no Space at all was closed, or belonged to an app that relaunched under new windows, so its filing is dropped and a hold left with nothing ends. Measured on macOS 27.0 (2026-09-28): a hidden app’s window and a minimized one read hosted, a closed one reads no Space; that a window away on another Desktop, on a fullscreen Space or still launching reads hosted is reasoned from the same read, not measured. The per-Desktop census cannot say it, since it lists user Desktops only and would read a fullscreen window as closed. Dropping a hold at the replay instead, for want of a live member, would have ended every hold whose windows are hidden, away or still launching — the very windows #1507 keeps a hold for. Without that reading nothing is judged, absent and never faked; the snapshot then stops carrying those unjudged filings — each one marked, never the session’s — so a closed window keeps a hold across one restart and not forever. A hold that goes home into its origin under another number takes every window it remembers along, a hidden app’s too, or that window’s return would re-create the retired number as an ordinary Space the next save captures. A hold whose Space no longer exists names nothing the snapshot records, so it ends at a restart. A restart of the Mac ends every hold without a separate store: a hold names its windows by the ids they had before it, which a restart retires, and no restore re-keys a hold, so one kept across it would hold nothing. The residue is in accepted limitations.

A replay under another arrangement keeps its declared modes. A snapshot names the arrangement it was captured under. Quit undocked and launch docked, and the docked profile’s 3 is not the laptop’s 3: replaying the record’s mode onto it is the merge by name #1230 refuses, and a device sitting (2026-09-28) came back with the docked Space floating because the laptop’s was. So a replay under another arrangement leaves the modes of the Spaces the live one declares, as a reconnect’s apply would; under the same one a mode set at runtime still survives (#633).

Discarding the saved arrangement deletes the record, not the hold. The #634 discard removes the snapshot files that carry it; ending the live holds too would turn each into an ordinary Space that the next save_profile captures. Resetting every setting ends them, as before.

[Principle]

A Space made on the fly is temporary until you put it in the profile (#1790). The Space Bar’s New Space, a plain create_space and a focus_space 7 or move_to_space 7 naming a Space that does not exist each make a Space for the task in hand. Without this rule every one of them becomes part of the profile by accident: the next Settings Save or Keep captures live, writes it into the file, and it comes back at every boot from then on. A scratch Space, or a digit pressed once by mistake, turns into arrangement. There are therefore two states and no third: temporary, or in the profile. A Space is in the profile only through a deliberate act — the profile or init.lua declaring it, Settings ▸ Spaces ”+”, create_space 7 profile, or Add Space ‹name› to this profile. The ”+” counts even though it lives in the draft until Save: the draft is the profile being edited, and a Space there is already the profile’s. A “permanent but unsaved” live Space is refused, because it is exactly the state the accidents above produced: some writes capture it, others do not, and nobody can tell which one it is in. So temporary is not a mark a Space is given when it is made but what a live Space IS when no profile, Standard or init.lua declares it and it is not held: a declaration ends it the moment it lands, and no way of making a Space can slip past it into a third state — which a mark set at creation allowed on both counts.

Invisible to every arrangement write, as a held Space is. The Settings Save, the gui.json space mirror, #1230’s per-profile record and the pins read the live Spaces through the one captured set, and a temporary Space is not in it. A held Space stays out because it belongs to an arrangement that is coming back; a temporary one because it belongs to no arrangement yet. The one write that takes it is save_profile, the whole-live snapshot: saying “write down what is on screen” is the deliberate act, so the Spaces it writes stop being temporary in the same step. Keep never takes one; that is the other half of the #1179 re-ruling above.

A switch holds it like any Space; a config load never touches it. A switch to another profile or to a composed Standard — Load profile, a Desktop binding, a monitor change — holds every Space the incoming arrangement does not name that still has windows (Every switch holds what the incoming arrangement does not name), and a temporary one is included: the reason for the hold — a switch must not spread one arrangement’s Spaces into another’s for good — does not depend on how the Space was made. It comes back temporary when its arrangement returns. An empty one is dropped, as any empty undeclared Space is. While it is held it wears the held marker, since a Space is never both. Dropping a temporary Space with windows on a switch, as #1790 first ruled, was refused on the same ground: its windows went into the fallback, and the Space the user was working in was gone when they came back. A config load is not a switch. A Settings Save runs one, boot runs one, Reload runs one, and a Load of the profile already live re-applies the same arrangement; each keeps a temporary Space where it is. The hold is therefore ruled by the switch, which the apply door already tells from a re-apply, and never by loadConfig.

It survives a restart: the other Space a restore creates. Quitting KiwiDesk, an update or a crash is not the user changing arrangement. The replay never creates a Space (#633), because a resurrected Space used to be written back into gui.json. That argument is about arrangement writes, and a temporary Space is invisible to all of them — the ground the held Space’s boot exception stands on. So a temporary Space rides every session snapshot and is re-created ahead of the replay, but only when KiwiDesk boots into the arrangement the snapshot was taken under. Booting into another one is a switch that happened while KiwiDesk was down, and it does not re-create the Space: unlike a live switch, a boot holds only what was already held. A number the booting arrangement now declares is that arrangement’s Space: the arrangement is the same one, so no two arrangements merge by name.

It deletes itself when its last window leaves. This is on, with no setting: a scratch Space nobody cleans up is the clutter the temporary state exists to avoid, and a setting would ask for a decision nobody thinks about. Three conditions keep it from surprising anyone. It fires on the departure that empties the Space, so a Space just made from the bar — empty from birth — stays until something has been in it. It waits while any screen shows the Space, so nothing vanishes under the pointer. And “empty” is the one spaceHoldsNothing, so a hidden app’s window or one on another Desktop keeps it. A screen’s last Space is never deleted, since the empty-display heal would mint it straight back.

Adding a Space to the profile is a write, not a draft edit. Add Space ‹name› to this profile and create_space 7 profile write the Space into the live profile’s file at once — its place in the list, mode, pin and icon — through the live-write door, so an open draft moves its saved baseline and keeps its other edits staged. A draft edit is the wrong shape: the Space is not in the draft to edit, and making the promote wait for Save would tie it to whatever else the draft holds. Both are refused where no profile file is live, since a Standard has no file to write into, and the button’s hint says what makes one.

Deleting from the profile asks first. A temporary Space is deleted at once, since nothing was written. Deleting a profile Space from the bar also removes it from the file, which switching back does not undo, so the bar asks, in the words Settings uses for its own delete. On the command line delete_space 5 profile is that confirmation, and a plain delete_space stays session-only. A Space init.lua or a Standard declares is not the profile’s to remove: the bar deletes it for the session and says it comes back on reload, and profile refuses, naming the source.

App Rules target profile Spaces only. A rule is config, and a rule pointing at a Space that drops on the next switch loses its target without telling anyone. A temporary Space is listed disabled, with the way to add it to the profile.

Settings shows them and never edits them. Settings ▸ Spaces lists profile rows, then temporary rows, then held rows, because a Space the user cannot find in the one page about Spaces reads as lost. The temporary and held rows are view-only — never in the draft, the save pill or the saved list — because the draft is the profile and these are not its Spaces. A temporary row carries the button that adds it to the profile. A Space’s number and shortcut follow its name, never its row.

A marker, never a name change. The same argument as the held Space’s: identity stays the bare name, so focus_space 6 and the digit chord work unchanged. A temporary Space wears an hourglass after its identifier, in the slot the held marker uses; a Space is never both, so they share it. The marker takes the identifier’s ink, dimming and lighting with it, and no plate: a filled disc belongs to the badges, which count or mark window state, and at a badge’s size a thin glyph like the hourglass stopped reading. It is drawn a little taller than the digits for the same reason, and the item grows by its slot. Like the held marker, it is not hidden by space_bar.set_sticky_badge(false), which hides the window-state badges only.

[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. The rejected reading — “macOS’s Displays pane owns true spatial layout; identity + order is enough here”, one row of equal-sized cards in physical x-order in place of a proportional canvas, a drag palette and a resolution list — is enough for a list of displays; 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. A list hides 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. A whole-chip Menu consumes the mouse-down that .draggable needs, which is why the keyboard 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 (ScreenCardChips.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 (#1240). Decoration may ride the accent (the main card’s bloom); the answer never rides hue alone (the “main” badge).

[Principle]

A rule must say something. An app rule states what an app does differently from an unruled one, so a row that states nothing must not be expressible. Each rule says something on its own — a Space (“Finder opens in work”), floating (“Spotify stays out of tiling”) — and either alone is a whole rule. Tiling was not while every app without a rule tiled. Detection now also floats some windows on a guess — a fixed size (#2070), no Dock icon — so an unruled app may float, and tile what a guess floats says something too (#2173). A Tile rule reaches the guesses alone: a dialog, sheet or panel and a Float rule for the same app still float, so a Tile rule never fights structure or a rule the user wrote. It is the app-level rule #1810 left for when someone asked, and the “Float windows automatically” switch is its every-app form.

One list per rule, because the rules have different scopes. A Space is title-blind — every new window of the app opens in it — while floating may match windows by title. Drawn as two facets of one row, the title pattern’s editor sat under the whole row and read as scoping the Space too — only the titled window goes to that Space (#1608). No wording fixes that, because the row’s shape makes the claim. So App Rules is two cards, Open in a Space and Float, each over its own store (GuiConfig.appRules, floatRules; AppRuleListsTests ▸ eachListReadsItsOwnStore), and a pattern’s editor opens under the Float row it qualifies (AppRuleListsWiringTests ▸ patternEditorScopesToFloat). An app may sit in both lists. This is the storage’s own shape, so it costs the engine nothing. A third card, Automatic floating, carries the escape for a guess (#2173): its checkbox on top and the Tile instead list it gates below it, so the Tile rows read as the checkbox’s exceptions. The checkbox names the windows the guess floats — fixed-size and menu-bar app windows (#2486) — since a bare “Float windows automatically” read as every window. It is one card rather than a list and a checkbox apart because the checkbox is what gives the list meaning — with it off nothing floats on a guess, so its rows grey (the trash and “Applies to” stay live, since other profiles may have it on).

Its Tile an app… picker lists only the apps the guess has floated a window of since KiwiDesk started (#2071), never every installed app: a Tile rule on any other app says nothing, and a guessed window can belong to a helper process no disk scan offers — Steam’s main window is Steam Helper’s, so a rule on Steam itself leaves it floating. Apps with a guessed window open lead, captioned with its title; the rest follow by recency, capped at GuessedApps.cap with the least recently seen dropped, and never persisted. A Dock-less app is recorded whatever the checkbox and the Tile rules say, so a profile whose live counterpart already tiles it still finds it; a fixed-size window is seen only where the live guess reads its zoom, close and Cancel buttons. Other… and Lua reach the rest.

This reverses “a rule is a sentence, and the sentence is the control” (#678 turn 14a). Running prose renders an empty rule most convincingly of all — “Finder opens in whichever Space you open it in and tiles normally” is fluent and says nothing — and with one rule per list there is no second clause for a sentence to join: the list title and the row’s one value already read as one statement (Float · All windows).

So neither list has an absent value. A row in the Space list holds a Space and a row in the Float list floats; the way to stop is the trash, so no value, dash or clear control exists to express “no rule”. The one exception is a stored profile’s tombstone — a profile un-pinning an app, or dropping a float rule, that its base profile makes. The row stays listed so the rule can be restored, and it draws as an absence: an em dash, since “Automatic” or “None” would be a value named after the absence. The dash says nothing aloud, so the menu speaks a word instead (.claude/rules/gui.md). With no Spaces declared there is nothing to open in; the Space picker greys and the card points at where to declare one.

A sentence can omit a clause; a table column cannot. That generalises to every table over an optional value, and it is why the two rules are not columns of one table. “Slack floats” is a complete rule with no Space in it; once the facets are columns, the Space column must render something for that row — an unset value, or a control meaning “no value here”, which needs help text to explain why it cannot be released. A control that must explain itself is the cost, not the remedy: a disabled control invites why can’t I change this?, and the answer is a sentence the user must read before the row means anything. Separate lists drop the empty cell instead of explaining it.

The list titles are the headings, and the rows take none. “Open in a Space” matches its picker, Open an app in a Space…, and “Open” covers every new window, not only a launch. Not On open, move to…: “move” suggests KiwiDesk moves windows that are already open. “Float” matches Float an app…, and is deliberately not Floating, which is the name of the Floating layout mode. A column heading over a bare Space name would repeat the title one line down.

The float scope values name WHICH windows: All windows and Windows titled…. Under a “Float” title they complete its sentence, and they name the scope positively, so no row reads as a negation to invert. Dialogs, sheets and picture-in-picture windows float whatever a rule says, which the user guide carries.

Each list’s picker composes its own rule, so a pick lands a complete rule and never a row with a defaulted facet (Settings UI patterns). A picker excludes only the apps its own list holds, since an app may carry both rules (AppRuleListsWiringTests ▸ pickersExcludeTheirOwnList).

Matching windows by title is never withheld. Windows titled… is a peer of All windows in the Float menu, not a deeper tier; Settings is one surface carries why an offer takes a row’s menu rather than a drawer.

The word order of a sentence with controls in it is the translator’s. Such a sentence 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 was not theoretical on the row that produced this ruling: ja and ko are verb-final, so no catalog edit could have made that row grammatical. SentenceFrame is the split, SentenceFrameTests holds it, the keyboard preview’s layout line draws through it, and any surface that wants a sentence inherits both.

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]

The App:Title colon syntax is serialization, not UI. The GUI assembles it and never shows it, 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 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 greyed chip on KiwiHub’s Profile line saying it can’t load (the remedy is the Config Issues window, which the menu bar offers while a load has failed). 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 the pointer’s way into the daily switches, and KiwiHub holds them (#2309). The layout, the profile and KiwiVision are what a user changes through the day, and they live in one place, KiwiHub’s view picker, reached by ⌃⌥L from the keyboard and by Open KiwiHub… from the menu bar. The row opens that side and shows its live chord the way a menu item shows a key equivalent, so every pointer trip teaches the faster path. The menu holds no switch of its own: a Layout submenu, a Switch Profile submenu and a KiwiVision row beside the panel were two ways to do one thing, learned twice, and the menu’s copy was the poorer one — no pictures of the layouts, no search, and submenus that are slow to steer with a pointer.

What else the menu holds is either a warning or app chrome. Settings… sits low, next to Quit, where every native menu-bar extra keeps Preferences, with What’s New and Update Available… beside Quit while they apply. Warning rows (Start Tiling until tiling is started, 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. Start Tiling, never shown beside the paused row, leads; the rest 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 view picker marks it with a ✓ — 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); a View Shortcuts… row (the shortcuts are KiwiHub’s middle side, one ⇧⇥ from where the row lands); and a Support row (it lives in Settings as a discreet link — a menu opened daily is no place for a recurring support ask).

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. A startup scan run as one synchronous block — ~10 s on a heavy session (109 running apps) — holds the very run loop the menu-bar item and the ⌃⌥K panel are served from, so an icon the user can see answers nothing, and the silence ends 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, 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 the switches it reaches 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).

The grey sits on the switches, not on the row that leads to them (#2309). Open KiwiHub… stays enabled while KiwiDesk starts, waits for Start Tiling or lacks the Accessibility permission, and the view picker greys its own items with the reason as text. A greyed row leaves its reason to a neighbouring row; the panel puts it on the item itself, and since the row and the chord open the same side, the pointer and the keyboard meet the same explanation.

[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 tells the user their windows have been arranged; chunked, it is read mid-scan rather than only after boot, so the claim 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).

A slow boot earns a quiet notice; the HUD stays rejected for every other boot (#1715, owner rulings 2026-09-27 and 2026-10-06). On a login boot, the dimmed mark and the menu’s count row are easy to miss, so a boot not ready 3 s after it began shows one capsule near the KiwiDesk item. The first ruling said ~2 s; a warm boot of the owner’s 50-app desk measured 2.1–2.7 s, which would have flashed it on every relaunch. What sets it apart from the rejected HUD answers each objection in turn:

  • Uninvited at every login: it appears only past the threshold, which a light session never reaches.
  • A dismissal policy: it has none to need. It never takes focus or the mouse, and it leaves by itself at ready, after at least 1 s on screen so it never flickers.
  • An anchor under a notch: it centres under the item where the item is visible, and otherwise sits top-centre below the menu bar — under the notch, where the item went.

It says the tour’s sentence word for word (BootCountText): the number counts apps gone through, and “Arranging your windows · N of M” would have read as a window count. It stands down where the shelf does (full screen, a presentation), while the tour owns the screen, and after an update relaunch, where “What’s new” already narrates the boot. The full-screen overlay stays rejected.

A restart restore keeps the same capsule up, with a count (#2133, owner ruling 2026-10-10). After a Mac restart, the cross-session match places only part of the desk at boot; the rest lands as windows reopen and once titles settle, ~30 s later. So at ready the capsule changes its line to “Putting your windows back: N of M” instead of leaving, and ends with “Your windows are back: N of M” at the title settle or once every window is back. Still past the 3 s threshold only, so a fast restore never flashes it, and still without focus, mouse or dismissal. It does not wait out the match’s 120 s bound: a window that never reopens would hold it up for two minutes, while a late one is still placed after it ends. A Load, a Desktop-bound switch or a monitor change drops it without an end line at the restore’s next pass, since the user caused that change; a stop or the arrangement reset drops it at once. An expandable list of the awaited windows was weighed and rejected: a click target brings back the dismissal policy this capsule exists without, it would show window titles at login, and every row’s wait is the one bound. Core reports RestorePhase; BootCountText stays the one narrator.

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.

[Principle]

A restart the user did not ask to see must not be seen. A quit spreads the windows out per quit.layout because nothing will manage them afterwards; a restart hands them to a new process a second later, so the spread and the re-tile that undoes it are a flicker with no reader. Two restarts are therefore in place: the update window’s Install and Relaunch, announced at Sparkle’s relaunch hand-off, and kiwidesk service restart replacing a loaded service, announced over IPC before its bootout (#930). The announcement is sent, never inferred: a stop and a restart reach the app as the same SIGTERM, so without it every guess is a guess about the user’s intent. It lives in memory only and expires after 30 s, so an announcement whose stop never came cannot make a later Quit keep its windows. Everything else — Quit, a login-item quit, service stop, a permission revoke and an automatic install, which Sparkle performs at the user’s own Quit and never relaunches — still gathers.

Nothing is gathered, parked windows included. Un-parking a hidden Space’s window into the grid is itself visible, and the next process parks it again. If the relaunch fails, the desk stays as a crash leaves it, and the session snapshot restores it at the next good launch — a crash’s recovery path, which already exists and is already trusted.

The service path is gated on code identity. The program the service will start must satisfy the designated requirement this process launched with — Sparkle’s own check, pointed at ourselves. A re-signed or ad-hoc development build would likely lose the Accessibility grant, and a process that cannot manage windows would strand every parked one, so it gathers instead. The update path takes no such gate: Sparkle has validated the update, and every release carries the same Developer ID. The residue of that trade is in accepted limitations.

Boot restores before it arranges. The scan finds windows in Accessibility order with no memory of their Spaces, so tiling what it found before replaying the session pulled every hidden Space’s windows onto the screen and parked them again. The session — or, after a crash, the newer autosave — is applied before the first pass, so each window lands in its remembered Space and slot, and a window left in place is set to the frame it already has. This holds for every launch, not only an in-place one.

After a restart of the Mac the same replay carries the windows the cross-session match paired at boot, re-keyed onto their new ids, so they too land before the first pass.

[Trade-off]

Sizing survives an in-place restart only. The in-place snapshot also carries the session’s resized splits, weights and Scrolling rest, the Monocle hold, and the float and sticky flags set by hand, since without them the new process lays the desk out differently and the restart is visible after all. After a quit and relaunch the sizing and the sticky flag start fresh, as they always have: a relaunch that resets sizing is a behaviour users rely on, and the two exits should not start to mean the same thing. For the same reason a snapshot a failed relaunch leaves behind gives up its session memory two minutes after the stop: the next launch, much later, is a launch after a quit, and restores the arrangement without the sizing. The payload is one build writing for the next, so a payload the reading build cannot decode costs only itself.

[Principle]

The quit gather places every window it can reach. A float left where KiwiDesk last put it is as stranded as a tile — a parked one sits in a screen corner — so the gather takes every Space’s floats, shown or not, beside its tiles. A native-fullscreen window, which lives on its own macOS Space, and a transient overlay such as a popup stay out (#1864).

On the condition that the next launch gives them back. No layout recomputes a float’s frame, so the gather overwrites the only one it has, and is admitted only because a stop carries each float’s state: its Space and frame, which every snapshot carries — a parked float’s capture in place of its corner — and the float flag set by hand, which a quit would otherwise start fresh. The flag rides every stop’s capture, a quit’s, an in-place restart’s and the file a logout’s freeze keeps, never a plain autosave: a crash moved nothing, so it still starts the flag fresh, as the in-place trade-off rules for sizing. A window that reopens after the replay takes its flag at arrival, beside its frame.

A restart soon after a Quit counts as that Quit’s relaunch. Quitting and then restarting the Mac is one gesture — closing up — and the Quit has just gathered every float, so the first launch after a boot or a login that began within ten minutes of the Quit matches its file by app and title, once, as it matches a logout’s (A restart of the Mac restores by app and title). Past ten minutes the Quit lets the arrangement go, as before: someone who quit, worked without KiwiDesk and restarted later has not asked for it back. Within one boot the login’s start cannot be read, so the launch stands in for it, which can only refuse more.

A restart of the Mac restores by app and title

Section titled “A restart of the Mac restores by app and title”

[Rationale]

Window ids never cross a boot or a login. A CGWindowID belongs to one WindowServer session, and a logout mints a new login session that reuses low ids, so a snapshot from either side of one names nothing, or names the wrong window. Its ids are never replayed (#1385). Instead each window record carries a stable key — the app’s bundle id and the window’s title — and the windows macOS reopens are paired with the records by app, then by title, then by rank. Bundle id alone settles every app that reopens one window, which is most of a measured desk; titles help only once they settle, since boot-time titles are generic, empty or localized differently, so a title is read only after a pass about 30 s after launch; and late windows keep arriving for a minute, so the match stays open for two. Windows sharing one title cannot be told apart by any key KiwiDesk holds, so they go back to their app’s Spaces in no particular order.

Earned by the shutdown. A snapshot the logout freeze wrote crosses a boot, and only once, by the first launch after it — the freeze is the user’s restart reaching a running KiwiDesk, which is the case where putting the desk back is what was asked for. A plain Quit crosses only when the next boot or login began within ten minutes of it, the one bound (#1864, A quit arranges floats too): someone who quits KiwiDesk, works without it and restarts later has not asked for an old arrangement back. There is no setting.

The freeze became a rollback. macOS quits the other apps about 14 s before KiwiDesk hears the power-off notification (measured 2026-10-09), so a freeze on that notice kept an autosave that already held 2 of 10 windows. The freeze therefore writes back the autosave from before the burst of closes, read off the departures the gone handler classified and the app exits. An earlier trigger on loginwindow’s own window was ruled out as fragile. A hide, a minimize or a Desktop departure inside the burst’s window stands the rollback down: such a burst is the user’s own work, not macOS’s quitting.

Deliberately left out. Held and temporary Spaces, pending filings and the per-profile records are keyed by window ids alone, and the match admits only Spaces the arrangement declares at boot, so their windows land as new; the session sizing an in-place restart carries never crosses a boot either. Titles are stored in the snapshot to be matched, and kept out of the unified log, which records only app and Space per pair. The pass that places a window whose title settled late moves it quietly, with no focus change and no window_moved_to_space event, as the boot replay it completes does, and never moves the window the user is in or one the user moved since launch.

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. A bulk re-sync sent to every observed app waits ~1 s on each such app — in series on the main actor for a config reload, and for a Desktop switch, whose reads run off it (#2347), the switch’s one fold held to its bound with that app’s departures left standing until its read returns — and an empty target Desktop costs 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 ▸ Files & Reset 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 action is named “Reset All Settings…” — its search and VoiceOver name; the row reads All settings over a Reset… button, its caption saying what it keeps — 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 (the Saved window arrangement row’s Discard) confirms nothing: the snapshots regenerate from live state within one autosave cycle, so it loses nothing that cannot come back. The tier-2 wipe confirms every time, through its own dialog — never the staged-edit discard gate, which only fires while dirty.
  • Export Log… (the Log row’s Export…) 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.

Home’s lower half holds the community; About is a sheet (#1536)

Section titled “Home’s lower half holds the community; About is a sheet (#1536)”

[Principle]

The page that welcomes also points outward. The three things a user asks about the app itself — where to talk about it, which version this is, whether an update is waiting — are answered where they arrive: on Home, below the cards, as a community strip (Discord, GitHub, Ko-fi) and one footer line (mark, name, version, update state, About KiwiDesk). A fact one navigation away is a fact most users never meet, and an update state that showed only in the menu bar was one. The strip stays quiet because the cards are the page’s object and nothing below them may compete: no container, no rule, no glass, no motion, and the services’ marks are template images in secondary ink. On this page colour means “this control is on”, and a brand blue or orange would say that about a link.

The footer line above also carries the tour’s permanent door (#1754) — mark, name, version, update state, “Show me around”, About KiwiDesk. The first-run banner retires once dismissed or after the first edit, so without it the welcome tour became unreachable; a replay of the app’s own introduction is one more thing asked about the app itself, and Home is where Settings opens, so it needs no search entry — About beside it has none either. One permanent door only: a second one elsewhere would make the reader ask whether they differ.

“What’s new” leads the footer’s links (#2308). The notes were reachable once, after an update, and then never again from inside the app — a user who jumped 2.3 to 3.1 and pressed Done could not go back to 3.0.0’s highlights. They are a question about the app itself, like the tour and About, so they join the same line, first, beside the version they describe: “What’s new · Show me around · About KiwiDesk”. It is the notes’ one permanent door, so About’s Release Notes link left with it (the exclusive placement below). The line never sheds a link as the window narrows: below its width the links wrap to a second, trailing line, the way every other control keeps its reach (Narrow windows drop the preview before they drop a control).

The update state is one component. What the channel last said lands in one store (UpdateStateStore, written by the updater and its Sparkle delegate) and one view draws it, on Home and in About alike, so the two cannot say different things; a surface that needs the state takes that view rather than a sentence of its own. Its states follow conventions the window already pays for: checking dims the arrow in place (grey, don’t hide); a found version wears a dot — the menu-bar mark’s idiom (#1013), in the Settings accent because this tree is one tint — and takes the menu-bar row’s own Update Available… action, since that is Sparkle’s door for bringing the waiting offer forward; a failed check keeps its Try again enabled because that failure is retryable. updatePending (#1013) is a different fact — a scheduled update waiting behind the gentle reminder — and stays on the prompt policy. The split that follows is the icon’s against the row’s: the menu-bar DOT is #1013’s reminder and lights only while a scheduled offer waits, while the menu row, Home and About draw the channel’s answer — so after a found update is put off with Later the dot goes and the row stays, and both are true.

About is a sheet over Settings, hosted by the shell. A body of content that asks no question and writes nothing is what a sheet is for (#859): one dismissal, Return and Escape alike. A separate window is one more own window for the tiler to classify (OwnWindowTiling’s census) and could be left open behind Settings showing a stale sentence; a Home destination would enter the back-chip navigation and promise settings About does not hold. The shell hosts it because the footer that opens it sits in Home, which reflows under it, and a host must outlive its opener (SheetPresentationSeamTests).

[Trade-off]

Link placement is exclusive. Home carries the community links only; About carries the version, the update state, License, Acknowledgements and the website only — the release notes are the footer’s “What’s new” (#2308). A link in both places makes the reader ask whether they differ, and a General ▸ About card is the same doubt one level up — so nothing about KiwiDesk returns to General, and Settings search does not reach the sheet either: About is one click from Home, not a setting (owner, 2026-09-20). The quick menu’s Check for Updates… row goes the same way: the footer asks, and the menu carries an Update Available… row only while there is one to install — a menu row that is usually a no-op is noise beside Quit. Language, appearance and start-at-login are General’s, and the reset ladder, log export and backup stay under General ▸ Files & Reset, the recovery shelf.

A “checking” state is only ever our own check. A scheduled background check is not narrated: nobody started it, so a spinner appearing on Home unbidden is motion the user did not ask for, and Sparkle’s delegate hears such a check by its outcome — SPUUpdaterDelegate asks permission before a check and reports nothing beginning (Sparkle 2.9.6). It shows as its result: the dated up-to-date sentence moves, or the found version appears.

[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 hides the shortcuts from everyone who has 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 & Gestures ▸ 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. The reading draws OVER the tally and the lines under it rather than in reserved height (#2532): a reservation sized for the deepest key left a gap under the tally at rest — a seeded digit alone carries three claims under All — and a slot that grew on hover moved the panel. The legend is covered only while a key is hovered, when the reading is what answers. The reading may draw down to the panel’s visible bottom, over anything under the preview, and never past it: a key holding more than fits ends on a line counting the rest and pointing at the row list, and the cut falls between claims, since a binding cut from its conflict is the half-said conflict this entry refuses. The caption leads with a hover hint, drawn and not spoken, since the board’s own description already reads every key.

    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” 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). 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 bound-wins reading, where binding a key silently drops its reserved ring, hides 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 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