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.
How to read this file (charter)
Section titled “How to read this file (charter)”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.
Architecture & product model
Section titled “Architecture & product model”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).
Accepted limitations
Section titled “Accepted limitations”[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.
Blocked by macOS (SIP)
Section titled “Blocked by macOS (SIP)”[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;
ScrollingLayoutpins 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 —
SLSSetWindowLevelonly 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 intoDock.appvia a scripting addition (SIP disabled); an own-connection fast path was measured useless for foreign floats (reference commit347231e).#418ships 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
dlsymis 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, thekiwideskCLI onPATH) 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.cssowns 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” andfocus_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.
Two install paths, one recommended per mode — never a chooser
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.
A restore replaces; it never merges
Section titled “A restore replaces; it never merges”[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):
| Candidate | Killed by |
|---|---|
zone | Stack’s master/stack zones (~88 sites) and drag.drop_zone.* (~172 sites, user-typed Lua) — two prerequisite renames to free one word |
desk | Substring of “KiwiDesk” (44 hits) and “desktop” (11) — a presence guard on it passes vacuously, and it collides with the word being separated from |
board | Substring of “onboarding” (18 key hits) |
pane | Substring of “panel” |
tile | “tiling” / “tiled” (15 hits) |
shelf | PaletteShelf in source |
area | SettingsArea is the #678 redesign’s central noun (271 hits in Sources/) |
workspace | Every competing tool’s word for the same thing |
room | Also means available area — “no room in the room”; substring-satisfiable in any presence guard |
deck | Nothing. 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:
| key | why it keeps its word |
|---|---|
general.language.display | “Display language” — the verb sense, not the device |
desktops.help | quotes Apple’s “Displays have separate Spaces” verbatim |
colors.liquid_glass.reduce_transparency.help | quotes 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.
Layout navigation & overflow models
Section titled “Layout navigation & overflow models”[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:
| Layout | Model | How it walks | Overflow → pile? |
|---|---|---|---|
| BSP | geometric | Navigation.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 |
| Stack | geometric | Navigation.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) |
| Grid | geometric | Navigation.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) |
| Scrolling | array-order | steps along the scroll axis (scrollingStep), geometric fallback cross-axis | no min-size cascade — the edge pile (#142; walled at a screen seam, #878) is a viewport pin, not an OverlapStack fallback |
| Monocle | array-order | steps along the orientation (monocleCycle) — same 1-D shape as scrolling | no — every window shares one frame |
| Track | array-order | steps 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) |
| Floating | geometric (live frames) | Navigation.neighbor with no slots: every member navigates by its live frame (the slot→frame fallback), flagged floats via the #488 float tier | n/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).
Layout and resize behavior
Section titled “Layout and resize behavior”[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-sidedSplitDomain), 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.minSizeMarginowns 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_tiledshipped 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).
Spaces, profiles & config ownership
Section titled “Spaces, profiles & config ownership”[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.
Sticky reach spans macOS Desktops (#1145)
Section titled “Sticky reach spans macOS Desktops (#1145)”[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.mdcarries 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_reachon/off/auto —autoclears 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.
A Desktop switch is not a close (#1207)
Section titled “A Desktop switch is not a close (#1207)”[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.focusedon a Desktop departure was the other option and is rejected:focusedmust 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_followis 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
vanishedwrites 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 isclosed; hosted on a shown Desktop while the app no longer lists it isclosedtoo — 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 fromCGWindowListCopyWindowInfo(.optionAll)— why not is onAXHelper.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 reportedclosed, a second destroy for an id that already sentvanished. The documented consumer pattern — events as dirty flags plus a re-query — already tolerates it, and a consumer filtering onclosedwas 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/awayWindowssplit, 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 andactivate()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.
Settings GUI & UX
Section titled “Settings GUI & UX”“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:
- a block gate keeps a live
?anchor outside the dimmed subtree (#527); - 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;
- 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’sonLiveProfileWrittenon 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).
MacChecklistProgressis 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;
MacSettingcarries 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
LoginItemCardshape. 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.
The app links the guide, in a language the site actually serves
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 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.
Open at login
Section titled “Open at login”[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.
Appearance (light / dark override)
Section titled “Appearance (light / dark override)”[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, notgui.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 flipKiwiCore.isGuiManagedand 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)..systemremoves the key entirely, so “follow macOS” leaves no trace, and an unknown stored value — a hand-edited domain, a case removed later — reads as.systemrather 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 (NSViewRepresentablecaptions, a visual-effect backing) resolve their appearance when made and do not re-read it when the modifier returns tonil, so Dark → System strands them dark while Dark → Light — a new concrete value — looks fine. AssigningNSApp.appearance = nilhands 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.darkAquaon 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).
AppearanceChoiceis a case with no AppKit in it; the mapping ontoNSAppearancelives 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).
Navigation & saving
Section titled “Navigation & saving”[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.HomeCardChromeTestspins 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 aProfile.currentFormatbump, 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 aProfile.currentFormatbump, 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 storedlayers,appRules,floatRulesandignoreRules. 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.
.saveLuaand.updateStoredProfilewrite 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.
Spaces
Section titled “Spaces”[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.
Shortcuts
Section titled “Shortcuts”[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/8layer with8on 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.mapcarries the⌥⌘family, so the app warns instead of the prose promising;SizeLayerSeedTestsholds 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:
- 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
.clearfrom luminance 144 to 141 at 15 % to 134 at 30 %, with chroma rising: SwiftUI’sGlass.tint(_:)dims by the colour’s alpha rather than carrying it, exactly as AppKit’stintColordoes ▸ 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.accentis no exception: at greyscale ~166 against the plate’s ~203 it is a darkening wash before the dimming is counted. - 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
foregroundStylesites are.secondary— and its two fixed hexes are not prose: the chip’s accent fill and the seal’sink, which sits on the button’s own fill rather than on bare glass..regularstrengthens this rather than weakening it: a calmer composite is a better backdrop for the vibrancy resolve to work against. - 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:
- 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 byGlassTint.paintwith the bars’ cap, so the dimmer measurement does not apply. - 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). - 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.
Overrides & appearance
Section titled “Overrides & appearance”[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_colorandborder.focused_colorall 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_coloris 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/#FF9F0Aseparates 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/#E8A33Dmeasures 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#B8D095measures 124, Neon#AA8909131), 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.SpaceBarAccentSeparationTestssweeps 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.focusedAccentDistinctpins 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 —SpaceBarAccentSeparationTestscomputes 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, andhighlight_colorIS that accent — in every bundled palette, without exception (#756;PaletteHighlightRoleTestsholds it). The rejected reading — borrow the secondary as a flourish — fails on what the highlight actually paints: underactive_indicator = outlineit strokes a 2 pt ring around the whole active item, and on aplainbar 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 ondrag.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
#FF375Fsimulated 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_coloris 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_colorsets the light/dark base;item_colorinverts against it (hover_item_colormirrors 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 andPaletteBarFillTests’ 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. Underliquid_glassthe 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_colordefaults 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#8E8E93cannot hold white at disc size (≈3.3:1). Grey is also the bars’ own vocabulary rather than an import, Monochrome having picked#636366for exactly this role before the default did (it has since taken the lighter#8E8E93under 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.jsonis sparse — it records what was changed — so a machine that never touched the badge takes the new grey at once. A profile snapshotsTilingSettingswhole, so every profile saved before this carries#B00020explicitly 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 tracksspace_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.
App Bar
Section titled “App Bar”[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
+nmenu, 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.edgekeeps its.topdefault, 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
retilefor the gesture’s life — the layout loop andstashInactivealike, viaTilingEngine.dragExemptWindow, the same kind of pin as the existing!isFloatingexemption — 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 isanimated: 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 —
cancelDragonly 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_spacefires 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 overdwell − 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_followalready 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_sourceoverride 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_appearancesub-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
APPMdata map upstream embeds in the TTF’smetatable, decoded directly in Swift and cached, which keeps bar rendering independent of the user’s Lua VM. Upstream’sicon_map.json/.lua/.shsnapshots 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.shwhich pins the upstream release inUPSTREAM.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_fontactive its Apps section leads with the same ligatures. The panel spans all layouts, so a Lua-only per-layouticon_sourceoverride 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.
Profiles
Section titled “Profiles”[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_profileandload_profilename 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.
Monitors
Section titled “Monitors”[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).
App rules
Section titled “App rules”[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)
Errors & the menu bar
Section titled “Errors & the menu bar”[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.)
Boot: the wait is narrated, never hidden
Section titled “Boot: the wait is narrated, never hidden”[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.
An in-place restart gathers nothing
Section titled “An in-place restart gathers nothing”[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.
A quit arranges floats too
Section titled “A quit arranges floats too”[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.
Recovery escape hatches
Section titled “Recovery escape hatches”[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.luavisibly 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.
Out of scope, on purpose
Section titled “Out of scope, on purpose”[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
UserDefaultsflag (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.stepsetting 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
KeybindingConflictsreports 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 (
KeyboardRingSeparationTestsderives 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
keyFreeonto 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.separationis 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