Files
Panama/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
T

139 KiB
Raw Blame History

Settings redesign — deferred test runs

No contract is executed while the settings redesign is in flight: many are live harnesses that open windows, drive overlays, and run display transactions on the real desktop. Everything below runs once, at the end of the redesign, with Gabriel's go-ahead, and failures get fixed then.

The run

  • panama test — the full suite (169 contracts as of the phase 6 Sound wave; the top-level README's count line is set to match and is itself checked by setup/readme-contract).

Known items to verify or investigate at the end

  • quickshell/welcome-contract — failed once ("the welcome screen did not open") during a full-suite run storming the live session; passed in isolation minutes earlier after the Welcome.qml comment edit. Suspected contention flake, not a regression. Confirm.
  • setup/readme-contract — counts contracts; keep the README number in sync as later phases add contracts (phase 2 added phone-page-contract).
  • Phase 2 additions already written and passing when last run: my-home-settings-contract (renamed from home-phone-settings-contract), phone-page-contract, extended home_assistant_bridge_test.py and kdeconnect_bridge_test.py.
  • Each later phase (Appearance, …) appends its new/changed contracts here instead of running them.

Phase 3 (Appearance) — append below

The contracts wave is done. Everything below is written and still to be RUN. Nothing in this list has been executed against a live harness: the static halves were checked against the tree, but every harness run and the full-suite pass are deferred to the end-of-redesign sweep.

New contracts (3)

Contract What it pins
quickshell/theme-catalog-contract config/themes.json shape (10 themes, 6 dark / 4 light), every palette and ansi block accepted by the model's own validators, moon and day byte-identical to the shell's pre-theme literals, ThemeCatalog.qml's embedded fallback carrying both, and Theme.qml holding no palette ternary.
quickshell/video-wallpaper-contract Three independent pause reasons (FocusModes.gameRunning, Battery.acOnline plus the preference, manuallyPaused) and pausing over mpv's JSON IPC; the mpvpaper argv (hwdec=vaapi, no-audio, loop-file=inf, input-ipc-server=); the hyprpaper stop/start choreography and Wallpaper.refreshActive(); Wallpaper.qml's video routing branches; the helper's 60-file cap, depth-2 scan and mp4/mkv/webm filter; the still frame for the lock screen; WallpaperIndicator in Bar.qml's right-hand row with its visible binding, spoken name and no animation; the two schema keys; the doctor check id.
quickshell/settings-titlebar-contract No minimize or maximize anywhere in the settings chrome (comments may say the words, code may not); height: shown ? 48 : 0 so hiding collapses rather than leaving a hole; buttonsLeft side-awareness with exactly one anchor released per element; the close button's activeFocusOnTab, Accessible.role/name, Return/Space handlers and focus treatment, and that it is the only button; Escape declared on the shell rather than inside the bar; the panamaTitlebar schema entry and its Appearance row.

Updated contracts (6)

Contract What it now pins
quickshell/accent-controls-contract Rewritten around the new editor: the nine surviving components and their qmldir lines, AccentPicker/ThemeProfilePicker staying deleted and unregistered, the four wells and their single apply() path, one hyprpicker invocation aimed by a remembered target, ColorWell's hex validation committing on Enter and focus-out but never per keystroke, the six HSV labels, the debounce (the write lives in commitTimer, changeChannel writes nothing, releaseTimer hands the sliders back), commitActive recomputing accentName through nearestCuratedName with selectProfile/updateActive both routing into it, Theme.qml reading ThemeProfiles.activePalette, the Themes and Theme editor tabs, four search labels, and the no-animation ban extended over all nine components. Live half rewritten against the rebuilt harness.
quickshell/theme-profiles-contract Node half rebuilt for the optional-field model: shippedProfiles() with no argument still returns the three built-in fallbacks (back-compat), the ten catalog records survive with palettes and ansi and no effects, palette/ansi/effects survive a stored record with per-key effect clamping, an invalid palette drops the FIELD not the profile, the caller's shipped list owns the id and name space, editProfile passes untouched fields through and a shipped fork carries the whole palette, nearestCuratedName mappings (mauve→orchid, gruvbox-yellow→amber, grey→slate, per scheme), and derivePalette/resaturatePalette/deriveAnsi/mixHex producing palettes the validators accept. Live half now pins the per-mode selection flow: a light/dark flip returns to the theme chosen on that side, never a forced default.
quickshell/desktop-style-contract titlebarMaximizeButton and titlebarDoubleClick asserted gone from the schema and from DesktopStyle, along with action-double-click-titlebar; panamaTitlebar added (bool, def true, group titlebar); the button layout pinned close-only on both sides with minimize/maximize banned from the function body; the Appearance greps moved to Fonts/Sizes/Rendering with exactly five FontPicker rows and their five role labels, plus the honest-titlebar subtitle.
quickshell/settings-ownership-contract The ColorScheme block was pinning inactiveBorderDark/inactiveBorderLight, which no longer exist. It now pins the inactive border as the active theme's gutter, bans a regrown literal, pins the accent border roles through the new hyprColor(value, alpha) signature, requires both roles to be restated on a theme change, and cross-checks Hyprland's startup literals against moon's and day's gutters in themes.json.
quickshell/lock-screen-settings-contract The "between Background and Shell typography" ordering check named a card that no longer exists. It now pins Background → Video playback → Lock screen order, and that all three sit on the Background tab.
quickshell/settings-search-contract Seven fixed cases added for the new surface: themes, theme editor, dark mode, Catppuccin, Gruvbox, video wallpaper, titlebar. The schema-label sweep already covers the new keys automatically.

Verified against the new tree, no edit needed

  • quickshell/settings-hardcoded-values-contract — its scope is Settings.qml, which the theme work did not touch; Theme.qml's literals are covered by theme-catalog-contract instead.
  • quickshell/wallpaper-service-contract — already carries the video routing greps.
  • quickshell/wallpaper-settings-contract, wallpaper-policy-contract, settings-nav-contract, settings-pages-contract, manual-contract, settings-docs-contract, qmldir-registration-contract, settings-sync-contract, settings-backup-contract — checked, nothing stale.
  • quickshell/panama-doctor-contract — already lists input.video-wallpaper.

Docs updated in the same wave

  • modules/settings/README.md — new Appearance section (Themes, Theme editor, backgrounds still and moving, the honest titlebar); the border ownership paragraph corrected to the theme's gutter.
  • manual/05-making-it-yours.md — rewritten Appearance chapter: the two galleries, per-mode memory, the four-well editor, video wallpapers, and why there is no minimize.
  • Top-level README.md — contract count 162 → 165, confirmed by setup/readme-contract.

Still open before the run

  • The parallel pipeline wave's files (gtk-theme-contract, lock-screen-theme-contract, palette-contract, the two bridge tests) are not counted above. If that wave adds contracts, the README count line and the count in this file both need bumping again before the suite runs.
  • AppearancePage.qml's tab property defaults to "background" while its own comment says Themes leads. Decide which is intended before the run; nothing currently pins it either way.

Phase 4 (Shell category) — append below

Spec: 2026-08-24-shell-category-redesign.md. Desktop & Dock became Shell (Bar · Dock · Control Center · Tiling · Workspaces), System gained Sync & Backup, and the dock got its feature wave.

Unlike phases 2 and 3, the static and stubbed contracts in this wave were run as they were written, and every one of them passed in isolation against the tree. Two things are still deferred. The live-harness halves were not run — the session was locked, and dock-position-contract opens a probe shell while settings-pages-contract starts a settings harness, so those ran static- only (PANAMA_SETTINGS_STATIC_ONLY=1) or not at all. And the full-suite pass, the only thing that catches contention between harnesses, happens next with Gabriel driving.

New contracts (1)

Contract What it pins
quickshell/bar-visibility-contract The bar's own neutral family: barFg/barFgDim/barFgMuted exist, each forced tone is anchored on one literal with both dims mixed off it, and the theme branch returns root.fg/fgDim/fgMuted by identity rather than a copied colour. All thirteen bar-text files bind to those tokens and none paints neutral text with the fg family (Theme.alpha(Theme.fg, …) hover and separator fills are allowed; semantic tones were never in scope). Bar.qml's scrim reads visible: Settings.barBackdrop, its shadow reads layer.enabled: Settings.barTextShadow over exactly one MultiEffect layer, and neither may be a literal true — both shipped hardcoded during the build, which is the regression this exists for. Plus the four widget gates ANDed with their state conditions, VitalsWidget's whole-pill visible, exclusiveZone: Theme.barHeight still literal, no animation anywhere in Bar.qml, and the two right-click jumps landing on bar.

Mutation-checked while writing: hardcoding the shadow, returning a literal from the theme branch, putting one widget back on Theme.fg, deleting the vitals pill's own visible, and adding a Behavior to Bar.qml each fail it with a message naming the actual problem.

Updated contracts (5)

Contract What it now pins
quickshell/dock-position-contract DockPinsEditorDockPinsStrip: preventStealing moved to the strip, the retired ↑/↓ buttons replaced by the strip's own keyboard path (Left/Right move, Delete unpins), and a new section for the live dock's drag-to-reorder — commits once on release, and measures a slot from a real icon rather than a constant, because a DockItem is taller than it is wide and a constant is wrong on one orientation.
quickshell/settings-jump-contract DockContextMenu's "Dock settings" now opens dock, not the retired desktop.
quickshell/settings-pages-contract Six new pages added to the page sweep (Bar, Dock, ControlCenter, Tiling, Workspaces, Sync); vitalsIntervalMs and the graphics ChoiceGrid now required on BarPage rather than AppearancePage, since the vitals are bar content and not surface appearance.
setup/projects-contract The saved-projects list moved from the deleted DesktopPage.qml to WorkspacesPage.qml.
quickshell/panama-commands-contract The launcher's new dock-add-apppanama-action dock-pin command.

Verified against the new tree, no edit needed

Every one of these was run and passed after the phase-4 changes landed:

  • quickshell/settings-search-contract — the new bar/controlCenter/ datetime groups and the re-pointed dock/multitasking/edges/master/ notices/focus routes are covered by the schema-label sweep already.
  • quickshell/settings-ownership-contract — the six intentional mirrors and the eleven README literals survived the rewrite; no new mirror was introduced.
  • quickshell/settings-nav-contract — 14 categories, 38 leaves, 2 retired ids (home-phone, desktop).
  • quickshell/manual-contract, gnome-handoff-contract (15 handoffs against 38 pages), control-center-contract, welcome-contract, accent-controls-contract, theme-catalog-contract, desktop-style-contract — checked, nothing stale.

welcome-contract passed in isolation again here, which does not settle the phase-3 flake above: that failure only appeared under a storming full-suite run.

Docs updated in the same wave

  • docs/settings.md and the 37 settings-* launcher commands — regenerated by panama-settings-docs and panama-settings-commands; both --check modes clean and both generators verified idempotent. settings-desktop is deleted, settings-bar/-dock/-control-center/-tiling/-workspaces/-sync are new, and dock-add-app joins them.
  • modules/settings/README.md — new Shell section (the five tabs, the bar token design and why it exists, the pins strip and the live dock's gestures, the Control Center rule that only real sections get toggles) and a new System Sync & Backup section. Appearance corrected from six tabs to five, saying where the Shell tab went.
  • manual/05-making-it-yours.md — new Shell chapter section (bar legibility, widget switches, why use24Hour is not there, the pins strip, the dock's drag/right-click/scroll/preview gestures, Add App to Dock, Control Center sections) plus Carrying settings between machines. manual/03-windows-and-workspaces.md — the multi-display workspace switch now points at Displays, which is where it actually is, instead of the deleted Desktop & Dock page.
  • Top-level README.md — contract count 165 → 166, recounted the way panama test collects (executable, or *_test.py, excluding fixtures and __pycache__).

Still open before the run

  • Four contracts in the working tree changed for reasons outside this phase and are not accounted for above: declared-assets-contract (pkill, from the video wallpaper work), declared-dependencies-contract (cmp → diffutils), panama-doctor-contract (29 → 30 check ids), and lock-screen-helper-contract / video-wallpaper-contract (theme-derived literals and the mpv IPC key quoting). Confirm each belongs to a wave that intended it before the suite runs.
  • tests/setup/update-command-contract is untracked and belongs to the separate panama update design, not to this redesign. It is inside the 166.
  • quickshell/dock-position-contract has never had its live half run since the strip landed: it opens a probe shell, and this wave ran under a locked session. Its static half is what was verified. Run it first in the sweep.
  • quickshell/settings-pages-contract passed static-only for the same reason. Its compositor-integration half — the settings harness, its IPC, and the page-open checks — is unverified against the six new pages.
  • Bar.qml has one stray indentation glitch at its first Row (line 105). Cosmetic, untouched here because the file is not this wave's to reformat.
  • 2026-08-24 full-suite run: 166 contracts, all green except displays-contract and switcher-contract, which are live interactive tests that cannot run behind hyprlock (both passed in the same day's unlocked run; neither subsystem changed in phase 4). Re-verify after unlock. Still pending — and displays-contract has since changed (phase 5), so this run is now the first one that exercises the new material as well.

Phase 5 (Displays) — append below

Spec: 2026-08-24-displays-redesign.md. The Displays page became canvas-first, the per-monitor record grew VRR / colour profile / bit depth / SDR trim / mirroring, and pushLayout stopped clobbering monitors.lua's colour values.

Nothing in this wave was run. The test window was closed while it was written: these are live harnesses that drive the real compositor through display transactions, and three agents were editing the tree concurrently. What was verified is listed as static below — evaluated directly against the implementation files without a harness, by loading monitors.lua under a stub prefs and by evaluating DisplayLayout.js in node. Everything else is deferred to the sweep.

Contracts changed (4)

Contract What it now pins Verified
quickshell/display-transaction-contract The extended record end to end: applyRecord merging one field at a time; vrr omitted when vrrMode === -1 and emitted when it is not; neutral sdrsaturation omitted rather than written; a mirrored rule asking for position = "auto"; the mirror x/y carve-out in matchesLayout, and that it does not leak to an unmirrored record; a framebuffer format with no 8/10 mapping skipping the bit-depth assertion instead of blocking Keep; confirm() persisting the whole record; old-shape stored blobs still validating and an impossible vrrMode not; and seven refusals (self-mirror, primary mirroring, absent target, out-of-range vrr/profile/depth/SDR). Bash and Python syntax; every new static grep checked against the landed Displays.qml (including the vrrMode >= 0 omission branch in monitorRule) and against the extended harness; the fake compositor's rule parser dry-run on a real monitorRule payload, covering the position = "auto" and mirror = branches and the mirrorOf: "none" readback spelling. Every IPC assertion is deferred.
quickshell/display-arrangement-contract Flipped: the canvas is no longer hidden below two displays. The visible: … monitors.length gate is now asserted absent from the DisplayArrangement element (by AST-free block scan, so the selector chips and Workspaces card may keep theirs), with a solo hint string, a draggable flag, and an enabled: binding on the DragHandler. Plus the mirror badge: Mirrors in the component, mirrorOf read off the rects, and two new harness fixtures — solo renders one rect with draggable == false, mirrored stacks rect 1 on rect 0 and flags it. Statically verified against the rebuilt DisplayArrangement.qml: every grep hits, the DragHandler's enabled: is found by the brace-matching helper, and the flip check was mutation-tested both ways — it fails on the pre-redesign page (where the gate sat on the enclosing card, not on the canvas) and passes once the gate is gone. The two new harness fixtures are deferred.
quickshell/display-layout-contract Mirror geometry in DisplayLayout.js: a valid mirror validates; the mirrored record keeps its stored coordinates through normalize (the primary's anchor does not apply to a position nothing reads back); it contributes nothing to bounds; canvasRects stacks its rect on its target's and carries mirrorOf/mirrored. Five refusals: self, absent target, mirroring primary, a two-hop chain, and a non-string. Statically verified in node against the real DisplayLayout.js — every expected value in the two new jq filters came from that run, including the 3140/80 the mirrored record keeps. Harness plumbing deferred.
quickshell/displays-contract The Lua consumer half: color_profile / bitdepth_value / vrr_value / sdr_value / mirror_value present, cm/bitdepth/sdrbrightness/vrr/mirror emitted under Hyprland's own key names, a mirrored entry's position forced to auto, neutral SDR saturation and vrrMode = -1 written as absence rather than as a value, and an entry with every new field impossible surviving with its geometry while each bad field drops. Plus the extended-record greps on Displays.qml. Statically verified: the whole LUA block was run against config/dot/hypr/monitors.lua with a stub prefs and passes. The Displays.qml greps were checked by hand and all hit. The live compositor half is deferred.

Cross-agent shapes these contracts now pin

Written from the spec's pinned API while agents A and B worked in parallel, and re-checked against their files as those landed:

  • DisplayArrangement.canvasSnapshot() exposes draggable alongside rects/scale, and passes canvasRects' mirrorOf/mirrored through — it returns canvasData.rects, not the solo-shrunk tiles, which is what the new fixtures assert against. Confirmed in the landed component.
  • The solo hint is pinned as the prefix One display connected rather than the full sentence, so the em dash cannot break the grep.
  • The mirror badge is pinned as Mirrors in DisplayArrangement.qml.
  • soloFixture/mirrorFixture mutate fixtureService.monitors and call resetDraft(), which is what the component's own onMonitorsChanged does. Whether that ordering settles before canvasSnapshot() reads back is the one thing only a run can answer.

Still open before the run

  • PreferenceSchema.qml's stale displays detail string — resolved: the detail now names color, VRR override, and mirroring, and the matching grep in displays-contract was updated in the same commit.
  • displays-contract also greps DisplaysPage.qml for selectedOutput, scalesForMode(, primaryFirstMonitors.map( and enabled: Displays.canConfirm. All four still hit, but the page was still the pre-redesign one when this was written — re-check them once the rebuilt page lands, particularly primaryFirstMonitors.map(, since the spec replaces the "Connected display" picker card with selector chips.
  • No contract file was added or removed, so the README count line stays at 166 and setup/readme-contract needs nothing.
  • Run order for the sweep: display-layout-contract first (pure geometry, no compositor), then display-transaction-contract (fake compositor on PATH), then display-arrangement-contract, and displays-contract last — it is the only one that drives the physical display, and it refuses to start from a scale that does not match what monitors.lua ships.

Phase 6 (Sound) — append below

Spec: 2026-08-24-sound-redesign.md. The Sound page became a complete PipeWire surface: honest device list with badges and a ghost row, per-channel speaker test, microphone test, per-app mic mute and per-app output routing, alert-sound theme, over-amplification, and native device profiles in place of the GNOME handoff.

Nothing in this wave was run. Three agents were editing the tree concurrently and every harness here drives the live audio graph — the page harness constructs the real Sound page against the session's own PipeWire, and the services harness starts a shell. What was verified is listed as static below: bash syntax on every contract, the JSON and pw-metadata fixtures parsed, and each service's own parsing logic replayed in node or Python against the landed source so the expected values in the assertions are the values the code actually produces.

New contracts (3)

Contract What it pins Verified
quickshell/sound-cards-contract SoundCards' parse of pactl -f json list cards: profiles keyed by name becoming an ordered list sorted by pactl priority (the fixture deliberately lists them out of order, so insertion order fails), a profile pactl marked unavailable kept and flagged rather than dropped, device.description as the card name, the port hint naming the connected port and reading exactly No port connected when none is, set-card-profile argv, an empty card or profile name starting no write, unparseable output and a failing read both degrading into lastError with an array still in cards, and recovery on the next refresh. Plus the static grep on the live -f json command, which the fixture seam means nothing else exercises. Statically verified: SoundCards.parse and portHintFor replayed in Python against the fixture — profile order, availability flags, descriptions and both port hints match the assertions exactly. Every IPC call is deferred.
quickshell/sound-routing-contract SoundRouting: every stream of a group moved, by object.serial and not by node id (both are plausible numbers in a log); a serial-less stream falling back to its node id; an empty group or unnamed sink running nothing; and routeToDefault releasing the pin through pw-metadata -n default -d <node-id> target.object and the legacy target.node, never through move-sink-input — moving a stream to the current default pins it there, which is the bug the button undoes. Plus the id asymmetry both ways, busy/lastError on refusal, recovery, and the spec-required comment above routeToDefault. Statically verified against the landed service: the argv shapes, the serial fallback, the two metadata keys and the early returns all read directly off SoundRouting.qml. The runs are deferred.
quickshell/sound-defaults-contract The one distinction the ghost row depends on: default.configured.audio.sink and not default.audio.sink. The fixture sets them to different values and writes the configured one first, so neither "last key wins" nor a machine whose configured device is present can make it pass by accident. Also: an unconfigured session reading as "" rather than as the effective device, an empty store, a non-JSON value on an unrelated key not taking the read down with it, a failed read leaving nothing invented, recovery, the ghost record and its label (Bluetooth address, AirPlay hostname, USB product string, empty), and a static ban on reading preferredDefaultAudioSink, which is null in exactly the case the service exists for. Statically verified: SoundDefaults.parse and label evaluated in node against all five fixtures — every expected string in the contract came from that run. The IPC half is deferred.

Updated contracts (4)

Contract What it now pins Verified
quickshell/sound-page-contract Rebuilt around the new page. Kept: the two SoundDeviceLists, the Dictation negatives and handoff, the balance and device-row pins, the Quick Settings sharing. Dropped: openGnomePanel("sound") and label: "Device profiles" — both now asserted absent, replaced by the native profile card (title:, SoundCards.cards, setProfile(, refresh(), lastError). Added: captureApplications filtered by AudioInStream and reaching SoundCaptureRow with a per-app mute, a tracked PwObjectTracker over capture nodes, the row hiding itself when nothing is listening; the ghost row asked of SoundDefaults.absent(root.output), rendering after the Repeater, and non-interactive by a brace-scan proving every TapHandler/HoverHandler carries !root.ghost; the device.api badges; over-amplification gated at 1.5 on the page and on Quick Settings' output slider only, with the >100% region marked; the four SoundRouting calls in ApplicationVolumeRow. The no-shell-out ban is unchanged on the four core files and now extended over all eleven page components. The whole static half was run against the landed tree and passes. The harness half is deferred.
quickshell/application-volume-contract Grouping pins unchanged; new clampVolume case for the max parameter — setVolume(group, 1.4, 1.5) lands 1.4 and unmutes, 2.5 clamps to 1.5, no max clamps to 1 (the default must not quietly follow the preference), a negative clamps to 0, and a non-numeric value changes nothing and returns false. Statically verified: the landed AudioStreams.js evaluated in node produced byte-identical output to the jq filter's expectations. The IPC run is deferred.
quickshell/osd-helper-contract Over-amplification and the blip, both out of settings.json. Every run now gets its own XDG_CONFIG_HOME, because the helper would otherwise read Gabriel's real preferences and pass or fail on which switches he has on. Pins -l 1.5 on volume up and down (wpctl clamps the result, so coming down from 130% would snap to 100% without it), -l 1 on the microphone in the same run, -l 1 for off / key absent / file absent / malformed JSON, the blip on up/down/toggle and not on brightness or microphone, silence with volumeChangeBlip false, and silence with no sound file — with the OSD still shown in every degraded case. Bash syntax only. The helper is agent A's and had not landed when this was written; see the open item below.

Cross-agent shapes these contracts pin

Written from the spec's pinned API while agents A and B worked in parallel, then re-checked against their files as those landed:

  • PANAMA_SOUND_CARDS_FIXTURE and PANAMA_SOUND_DEFAULTS_FIXTURE are file paths the service cats in place of the live command, read once at singleton construction. So a contract changes what the file says between cases rather than where it points, and deletes it to make a read fail. Confirmed against both landed services.
  • sound-services-harness.qml is new and shared by all three service contracts. Whichever contract is running sets an inert fixture for the two services it is not testing, so nothing reaches the live daemon and every line in a command log belongs to the service under test.
  • SoundDeviceList asks SoundDefaults.absent(output) rather than comparing configured names itself — the service owns both the comparison and the label. The contract followed B's refactor to that shape.
  • The ghost label is A's SoundDefaults.label(), so it reads Bluetooth device (AA:BB:CC:11:22:33) with parentheses. SoundDeviceList briefly had its own tidyName() producing the same string without them; the harness and the assertion track the service's version.

Docs updated in the same wave

  • services/SettingsSearch.qml — the seven hand-written Sound entries from the spec (Balance, Speaker test, Microphone test, Alert sound, Applications using the microphone, Move an application's audio, Device profiles) on top of the four that were already there, and groupPages gained "sound": "sound" so the new schema group self-indexes.
  • Top-level README.md — contract count 166 → 169, recounted with the same find setup/readme-contract uses; that contract was run and passes.

Still open before the run

  • osd-helper-contract is written against a panama-osd that had not landed. It assumes two things of agent A's helper: that the settings file is resolved as ${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json (the panama-idle / panama-lid spelling, not panama-palette's PANAMA_SETTINGS override), and that the blip's sound file can be overridden with PANAMA_OSD_BLIP_SOUND (matching the existing PANAMA_OSD_* seams in the same script), which is the only way to exercise the missing-file branch deterministically. If A spelled either differently, the env names in run_helper are the only lines that need changing. Reconcile before running.
  • The contract also now asserts -l on the down step, which the pre-redesign helper did not pass. The reasoning is in the contract; if the landed helper only limits the up step, that is a real bug at 150% and not a contract to relax.
  • sound-page-contract's runtime half asserts .captureApplications >= 0 and .captureRowVisible == (.captureApplications > 0) — true on a machine where nothing is recording, which is the ordinary case. Getting a positive capture count under test would mean holding the microphone open from the contract; the grouping itself is covered by captureTypesValid and by the static greps.
  • No contract in this wave has had its harness started. Run order for the sweep: sound-defaults-contract, sound-cards-contract, sound-routing-contract (all three fixture-fed and cheap), then application-volume-contract, then osd-helper-contract, and sound-page-contract last — it is the only one that constructs the real page against the session's own audio graph.
  • Nothing in this wave plays a sound on purpose, but sound-page-contract constructs SoundPage, whose microphone test and channel strip are one IPC-less click away from pw-play. The harness exposes no method that triggers either; keep it that way.

Stabilization pass — 2026-08-24 — THE RUN HAPPENED

Three read-only reviewers swept the whole redesign (37 findings), three fix waves applied them all, and the full suite then ran three times with Gabriel's go-ahead: 162/169, 168/169, then 169/169 green. The seven interim failures were four suite-contention flakes (pass solo; theme-profiles hardened with a catalog-settle wait) and three contract bugs (display-layout's stale mirror expectation, osd-helper's non-atomic stub log + a set -u local expansion trap, sound-cards' jq context rebind). displays-contract and switcher-contract — the locked-session holdovers — both pass unlocked. Later phases append new deferred contracts below as before; this line is the baseline they diverge from.

Phase 7 (Notifications & Focus) — append below

Spec: 2026-08-24-notifications-focus-redesign.md. Notifications & Focus became two tabs. Per-application rules grew from one bool to seven fields with a real editor behind them, Do Not Disturb gained a second exception, and focus modes got create/rename/delete/reorder plus trigger-kind editing on a page of their own.

Nothing in this wave was run. Three agents were editing the tree concurrently, and the one runtime harness here (notification-app-rules-contract) boots a real Quickshell against a private D-Bus session. What was verified is listed as static below: bash -n on every changed contract, and — for the two contracts whose assertions are computed — the extracted blocks replayed in bun and python3 against the landed services, so the expected values in the assertions are the values the shipped code actually produces.

New contracts (0)

None. The redesign added surface to services and pages that already had contracts, so the README count line stays at 169 and setup/readme-contract needs nothing.

Updated contracts (4)

Contract What it now pins Verified
quickshell/notification-app-rules-contract The rule shape, as two properties rather than a literal: every field past enabled is optional (an old {enabled}-only blob reads back with sound/display/urgency/lastSeenMs/name/icon at their defaults) and the field set is closed (unknown keys, the stale lock-screen pair, and out-of-vocabulary display/urgency values are all dropped; a non-numeric or negative lastSeenMs reads as 0). Plus: forgetApp deleting the key rather than writing an all-defaults rule; rememberApplication stamping lastSeenMs/name/icon from the clock; effectiveUrgency and all four of its consumers (playBell's low check, notificationTimeoutMs's critical duration, the breakthrough gate, NotificationCard's critical edge); the per-app sound switch in playBell; display: "history" reaching history and unread but not the popup list or the bell; and the popup gate literal !root.doNotDisturb || FocusModes.allows(appId) || breaksThrough with breaksThrough being Settings.criticalBreaksThrough ANDed with effective-critical. The schema key and its Settings reader are pinned too. Page assertions now read the page and the components it delegates rows to (NotificationAppRow.qml), so pulling the expanded body into a component does not read as the feature being deleted. Runtime: four new fixtures — historyOnly, forgetting, urgency, breakthrough — and the two existing jq literals became field-by-field predicates, because lastSeenMs is a wall clock that cannot be written down. The whole static half was run against the landed tree and prints PASS. The runtime half is deferred.
quickshell/focus-modes-contract Every existing pin is untouched and still passes: conditions-not-alarms, the midnight-crossing / malformed / zero-length schedule arithmetic, single DND ownership, the gaming hook reporting rather than silencing, the retired gamingSilenceNotifications key staying out of the schema, and the exception list being both consulted and editable. New: createMode/removeMode/renameMode/moveMode/setTriggerKind/seedTrigger/uniqueId/hasMode by name; a bun replay of seedTrigger (all five kinds and their seeds, handed-in fields kept, unknown kinds refused with null rather than stored) and of uniqueId (slugging, suffixing past a taken id, and the "mode" fallback); the "order is priority" claim having to appear on the page; and the manual-mode semantics being preserved rather than invented — automatic still filters manual-only modes out and the fall-through still carries its note. The editable-list needle moved from NotificationsPage.qml to the Focus surface (FocusPage.qml + Focus*.qml), because the chips live in FocusModeRow.qml. Statically verified end to end: every grep re-run against the landed files, the schedule arithmetic replayed in python3, and the new API block replayed in bun — all green.
quickshell/settings-pages-contract Focus added to the component list (root type SettingsPage, no copied Flickable scaffold) and focus to the runtime page-routing sweep. The four Notifications sliders and the zeroLabel: "Never" rule are unchanged and still hit. Static half verified: FocusPage.qml's root type and the four slider regexes replayed in python3. The routing sweep is deferred — it starts an isolated shell.
quickshell/settings-jump-contract GamingPage's "Open Focus" now has to go through ShellState.openSettings("focus"), and assigning ShellState.settingsPage by hand is banned there — the old code did exactly that, which skipped SettingsRoutes.resolve() and so skipped this contract's own guard entirely. NotificationCard's jump stays "notifications" and is unchanged. Statically verified against the landed GamingPage.qml and NotificationCard.qml.

Contracts deliberately left alone

  • quickshell/search-routing-contract — needed no edit. It derives page files from SettingsShell's switch, so focus resolved to FocusPage.qml the moment B landed the case and the Component. Replayed by hand against the landed tree: 144 routed settings, 0 violations with the focus group moved.
  • quickshell/settings-window-contract — enumerates no tabs. It routes displays and the retired desktop id only.
  • quickshell/settings-ownership-contract — its duplicate-row scan and its groupPages parse were replayed against the edited SettingsSearch.qml and still pass.
  • setup/readme-contract — no contract file added or removed; find still counts 169 and the README still claims 169.

Docs updated in the same wave

  • services/SettingsSearch.qmlgroupPages "focus" moved from "notifications" to "focus", plus eight hand-written entries: Do Not Disturb, Quiet hours, Critical alerts break through, Application notification rules, Forget an app's notifications, Per-app notification sound, and Banners or history (all → notifications), and Focus session duration (→ focus). Checked against settings-search-contract's fixed query list: none of the new labels or details contains any of its queried substrings, so no existing top result moves.

Still open before the run

  • A live bug this wave removed, worth knowing about. notification-app-rules-contract used to perl-graft a temporary notificationAppRules schema key into its copy of PreferenceSchema.qml, from the era before that key shipped. The key ships now, and the graft's anchor comment still exists — so the contract was defining it twice in the copied schema on every run. The graft is gone, replaced by an assertion that both notificationAppRules and criticalBreaksThrough are present.
  • NotificationAppRulesHarness.qml's fake notification now spells out urgency, expireTimeout and hints. It previously left all three undefined, which meant the timeout path and the urgency path were exercised in their undefined branch rather than their ordinary one. If a fixture behaves differently than expected on the first run, that change is the first place to look.
  • The breakthrough fixture drives Settings.criticalBreaksThrough by writing the preference and reading the binding back in the same JS call. That is synchronous through DesktopPreferences.set (it reassigns values and bumps revision before returning), but it has not been observed. If the fixture reports through: 0, suspect binding timing before suspecting the gate.
  • focusDurationMinutes is now editable on two pages: a SliderRow on WorkspacesPage.qml and segmented chips on FocusPage.qml. No contract catches it — settings-ownership-contract only scans the five schema-bound row types and the chips are a SegmentedRow reading DesktopPreferences directly, and search-routing-contract only sees setting: rows — but it is a real ownership violation by modules/settings/README.md's own rule. Decide before the run: either the Workspaces row goes, or the key's group moves to focus and the mirror gets named in settings-ownership-contract.
  • A stale comment in config/PreferenceSchema.qml above focusDurationMinutes still says "The focus group routes to Notifications, which is where focusModes renders." It routes to focus now. Left for the schema's owner rather than edited across agent lines.
  • Run order for the sweep: focus-modes-contract first (pure static, no compositor and no shell), then settings-jump-contract and search-routing-contract (also static), then notification-app-rules-contract (private D-Bus, isolated shell), and settings-pages-contract last — it starts an isolated Quickshell beside the live one and its own cleanup is what protects the running session.
  • Nothing here plays a sound on purpose, but every delivery fixture reaches playBell, which shells out through SoundFeedback.playCommand. That was already true of the existing exercise fixture; the new fixtures add four more chances for it. Keep the harness free of anything that turns the volume up.

Phase 8 (Input) — append below

Spec: 2026-08-24-input-redesign.md. Keyboard, Mouse & Touchpad and Dictation were rebuilt around keycaps, dropdowns and a searchable shortcuts browser, and seven new compositor-backed preference keys landed with them.

Nothing here was run against a live harness. Three agents edited the tree concurrently. What was verified is listed per contract below: bash -n on every changed contract, the two source-only contracts run end to end, and the three compositor-shape contracts replayed offline — hyprctl descriptions and hyprctl getoption are read-only queries, so their answers were captured once and the contract logic replayed against that snapshot with a stub on PATH, never against the running compositor mid-edit.

New contracts (0)

None. The redesign added rows, components and schema keys to surfaces that already had contracts, so the README count line stays at 169 and setup/readme-contract needs nothing. (find counts 169; the README says 169.)

The seven new schema keys, per key

Each had to satisfy three contracts at once. Replayed against the landed PreferenceSchema.qml, hypr/input.lua and a captured hyprctl snapshot:

Key Hyprland option enum-hypr-map schema-hypr-shape hypr-prefs
focusOnClose input:focus_on_close PASS — enum, all three published values offered PASSreadAs: "int", answers int PASSprefs.getInt("focusOnClose", 0), def 0
scrollMethod input:scroll_method PASS — string enum, every offered word in the published list PASSreadAs: "str", answers str PASSprefs.get("scrollMethod", ""), def ""
scrollButton input:scroll_button n/a — type: "int", not an enum PASSreadAs: "int", answers int PASSprefs.get("scrollButton", 0), def 0
cursorHideWhileTyping cursor:hide_on_key_press n/a — bool PASSreadAs: "bool", answers bool PASS — def false
cursorWarpOnWorkspaceChange cursor:warp_on_change_workspace n/a — bool over an int option, so the enum rule does not reach it PASSreadAs: "int", answers int PASSprefs.getInt(..., 0) vs schema false; the contract's own true/false→1/0 normalization is what makes those agree
touchpadClickfinger input:touchpad:clickfinger_behavior n/a — bool PASSreadAs: "bool" PASS — def false
touchpadTapAndDrag input:touchpad:tap-and-drag n/a — bool PASSreadAs: "bool" PASS — def true

Two of those are decisions, not just passes, and both are recorded in the schema itself:

  • focusOnClose was specced as a two-way choice. The compositor publishes three ({"mru":2},{"cursor":1},{"next":0}) and 0 is what this desktop runs today, so a two-option dropdown would have hidden the shipped default from its own control. enum-hypr-map-contract fails an enum that drops a published value, and would have caught it — verified by deleting value 0 from a copy of the schema and watching it fail with exactly that message.
  • cursorWarpOnWorkspaceChange is a switch over an option with three states. force (2) is deliberately unreachable from Settings. enum-hypr-map governs enums only, so nothing fails — which is the point of writing it down here.

Updated contracts (6)

Contract What it now pins Verified
quickshell/enum-hypr-map-contract String-valued enums are now checked at all. The parser only ever collected numeric value:s, so accelProfile, masterOrientation, masterNewStatus and windowLayout were silently skipped and scrollMethod would have been too. String options carry no map; Hyprland states their accepted words inside the description ([2fg/edge/on_button_down/no_scroll]), so those are parsed and checked one way only: an offered value the compositor does not name fails; a named value Settings does not offer does not, because that is a product decision (accel_profile's custom needs a scroll_points curve and is a stated non-goal). Empty string is always allowed — it is how a schema entry says "leave the compositor's default", which is what [[EMPTY]] reads back as. Options with no bracket list print a line saying so and are skipped rather than failing. The numeric rules are untouched. Replayed offline against a captured hyprctl descriptions (353 options) and the landed schema: PASS, 11 mapped enums (was 9). Both new failure directions exercised on a scratch copy — dropping focus_on_close's value 0 fails, offering "two_finger" for scroll_method fails.
quickshell/xkb-presets-contract Rewritten for the Advanced disclosure. The raw keyboardOptions, keyboardVariant and keyboardLayout fields must still be editable on the Keyboard page — matched as blocks, not one-liners, so nesting them inside an expander is fine — and collapsed-but-present passes while absent fails: the section must be named ("Advanced") and some onClicked/onTriggered/onToggled handler must actually open it, and no raw field may be pinned visible: false. XKB values stay pinned exactly (caps:escape_shifted_capslock, caps:ctrl_modifier, compose:ralt, grp:win_space_toggle) because moving one changes somebody's keyboard; row labels are now matched loosely and case-insensitively, because "Compose key" → "Compose" is a wording decision. The category-preservation rule, both helper signatures, and the three input.lua/schema default needles are unchanged. Run end to end against the landed tree — it is source-only and touches neither compositor nor shell. PASS. Nested-block matching and the "no handler ⇒ fail" direction both exercised.
quickshell/keybinds-contract A static presentation half ahead of the existing live count check, which a wall of 130 rows and a searchable browser pass identically. Page and ShortcutRow.qml are read as one source, so moving a control between them is not a failure: Keybinds.grouped() is what supplies the group order, KeycapChord is what draws chords, the filter exists and is case-insensitive and matches on description, a filtered list says how many of how many it is showing, the header count comes from Keybinds.binds.length, and the note explaining why there is no GNOME keyboard handoff survives. Count-match, description-completeness and chord-rendering rules are untouched. Static half replayed against the landed ShortcutsPage.qml + ShortcutRow.qml: all needles hit. The compositor half is deferred — it boots a Quickshell harness.
quickshell/keybind-rebind-contract UI needles added to the static half (the one that already runs under PANAMA_KEYBINDS_STATIC_ONLY=1). Page + row read as one source: ShortcutCapture is still what reads key presses (a page that grew its own handler would capture SUPER as a bind of its own), boundTo is called before rebind on the source line order, Change/Reset/resetBind/resetAll/isOverridden all still exist, binds are identified by luaChord, and no rebind/resetBind call is keyed by description — the regression that once moved every bind sharing one and cost the XF86Calculator key. The restore-all row and its live differs-count are pinned on the page. Engine needles and the whole live half are untouched. PANAMA_KEYBINDS_STATIC_ONLY=1 run against the landed tree: PASS (static). All 20 needles individually replayed. Live half deferred.
quickshell/settings-pages-contract Shortcuts and Mouse added to the root-type/no-copied-Flickable sweep (neither page was ever in it), mouse added to the runtime routing sweep, and a hand-written check that all seven new keys render on MousePage.qml — by setting: "key" or by commitPreference("key". That second spelling is why it is hand-written: dropdowns now render through OptionPickerRow, which takes label and options from PreferenceSchema.spec() and commits by name, and has no setting: property at all. Existing Home/Bar/Notifications/ScreenIntelligence pins unchanged. Static half run: PASS. Routing sweep deferred — it starts an isolated Quickshell beside the live one.
setup/dictation-contract Every "where text lands" pin is unchanged and none of them conflicts with the on-page test. They live on panama-dictate and keybinds.luais_speech rejecting [BLANK_AUDIO], the guard actually being called before typing, the newline collapse, wtype tried before wl-copy, one press bind and one release bind — and nothing in them constrains which window has focus. The Try-it field sends the same start/stop the hotkey sends and merely holds keyboard focus while wtype types. What is new is two needles for the risk the test flow did introduce: the page must not spell out scripts/panama-dictate (the service publishes that path once, and a second copy would go stale silently, since the page's status readout comes from the service and would still be right), and a page that runs a Process must go through Dictation.helper. Run end to end — it is greps plus a python3 import of the helper, no compositor and no shell. PASS, including both new needles against the landed DictationPage.qml.

Verified against the new tree, no edit needed

  • quickshell/schema-hypr-shape-contract — derives everything from option: "...", readAs: "..." pairs in the schema, so the seven new keys entered it the moment they landed. All seven extract cleanly and all seven readAs values match what hyprctl -j getoption answers with. Two were easy to get wrong and are worth naming: cursor:warp_on_change_workspace answers int despite being a switch in the UI, and input:scroll_method answers str despite the neighbouring scroll_button answering int.
  • tests/hypr/hypr-prefs-contract — pure static, and run: ok, 77 compositor-owned keys read at config time, up from 70. All seven new keys have a prefs.get()/prefs.getInt() in config/dot/hypr/input.lua with a fallback equal to the schema default.
  • quickshell/gnome-handoff-contract — needle-free by construction (it derives both sides). Run: ok, 14 handoffs checked against 39 pages. The Keyboard page still has no GNOME handoff and still explains why.
  • quickshell/schema-hypr-shape-contract, tests/hypr/hypr-prefs-contract and quickshell/gnome-handoff-contract are the three above. setup/readme-contract is a fourth: no contract file was added or removed, find still counts 169, and the README still claims 169.

Docs updated in the same wave

  • services/SettingsSearch.qml — three hand-written entries: Rebind a shortcut (→ shortcuts), Pointer test area and Connected input devices (→ mouse). "Key repeat" and "Scroll method" arrive automatically from the schema, as the spec expected. Checked against settings-search-contract's fixed query list: of its 27 pinned queries only pointer matches any new entry, and "Pointer test area" sorts after "Pointer focus", "Pointer size" and "Pointer speed" in the same prefix rank, so no pinned top result moves. Both new pages are leaves in SettingsRoutes, so the "routes to a page anyone can land on" sweep holds.
  • No settings docs or launcher commands were regenerated here — that is the orchestrator's step after the schema settled.

Still open before the run

  • settings-ownership-contract and search-routing-contract are now blind to dropdown rows. Both scan for setting: "…" inside a fixed list of row types; OptionPickerRow is in neither list and carries no setting: property. accelProfile, followMouse, focusOnClose and scrollMethod are all invisible to them on the rebuilt MousePage.qml. Nothing fails today — none of those keys is a duplicate — but a duplicate introduced through a dropdown would not be caught. settings-pages-contract now pins the seven new keys directly as a stopgap; the real fix is teaching both scans the PreferenceSchema.spec() / commitPreference() spelling. Owner: whoever holds those two contracts next.
  • xkb-presets-contract now requires a raw keyboardLayout field, on the reading that the layout dropdown's "Custom…" has to reveal somewhere the code can actually be typed. It passes against the landed page. If the layout editor is ever folded into the dropdown itself, that needle is the one to revisit — the intent is "the raw code stays typeable", not "it is a TextEntryRow".
  • keybinds-contract pins the literal word showing in the filtered-count line, because the spec names that wording ("showing N of M"). It is the one prose needle in the new static half; everything else keys on structure.
  • Dictation's Try-it field and the clipboard fallback — handled, but worth knowing. panama-dictate falls back to wl-copy when wtype is missing, by design, and on the Try-it row that means the words land on the clipboard rather than in the field the page just focused. The page says so: a "Typing — Missing" row appears when Dictation.typingAvailable is false. Nothing to fix; worth a look during the run if a machine without wtype is around, since that branch has never been seen.
  • Run order for this phase: the two source-only contracts first (xkb-presets-contract, gnome-handoff-contract), then the static halves (hypr-prefs-contract, PANAMA_KEYBINDS_STATIC_ONLY=1 keybind-rebind-contract, PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract), then the two compositor-query contracts (enum-hypr-map-contract, schema-hypr-shape-contract — read-only, but they want the real compositor), then the harness contracts (keybinds-contract, keybind-rebind-contract in full, settings-search-contract), and settings-pages-contract last, as before: it starts an isolated Quickshell beside the live one and its own cleanup is what protects the running session.
  • keybind-rebind-contract's live half rebinds Terminal to SUPER + SHIFT + F9 against the real compositor with an isolated XDG_CONFIG_HOME. That was true before this phase and is unchanged, but it is the one contract in this wave that writes to the running keymap, so it wants a quiet moment.

Phase 9 (Network & Sharing) — append below

Spec: 2026-08-24-network-sharing-redesign.md. Connections became a full network manager (per-connection details, forget, autoconnect, MAC randomization, VPN import, hotspot, enterprise Wi-Fi, airplane mode, system proxy), Firewall grew its add side and a zone browser, Printers gained per- printer defaults and job hold/release, and Sharing got copy and structure work. The last two GNOME punt rows in the app died with the "Owned by Fedora" card.

Nothing here was run against a live harness. Three agents edited the tree concurrently. What was verified is listed per contract below.

New contracts (1)

quickshell/network-tools-contract. The README count line moves 169 → 170; setup/readme-contract was run and passes ("170 contracts, as documented"), counted the same way panama test collects the suite.

Verified

  • quickshell/network-tools-contract — RUN END TO END, PASS. It is safe to run because it never reaches the machine: env -i with a stub directory first on PATH, stubs for nmcli/gsettings/rfkill plus every D-Bus client, a PyGObject stand-in on PYTHONPATH whose require_version always raises, and both D-Bus bus addresses pointed at sockets that do not exist. It asserts the first two of those before running the helper at all, and refuses to continue if nmcli, gsettings or rfkill resolves anywhere but the stub directory. Checked with a debug copy that it is doing real work rather than passing vacuously: the stub's terse listing drives the shipped parse all the way to {"ip4":"192.168.7.42/24","gateway":"192.168.7.1","dns":["192.168.7.1", "1.1.1.1"],"mac":"AA:BB:CC:DD:EE:FF","macRandomized":true,…} while the three secret properties in the same listing are absent from it.
    • The libnm/D-Bus branch of join-enterprise is deliberately not exercised — running it would add a real connection profile. It is covered statically instead, by an AST walk that fails if any command list in the helper carries a password-shaped identifier. The nmcli fallback is what the dynamic half drives, and there the password is asserted present on the editor's stdin and absent from argv, stdout, stderr and disk.
  • quickshell/connectivity-contract — static half run, and it found a bug. See "Still open" below. The new enum check reads the member list out of the installed quickshell-network.qmltypes rather than a hand-kept list, so it cannot drift the way the two bugs it catches did.
  • quickshell/firewall-contract — static half run, PASS (everything up to the first live firewall-cmd read).
  • quickshell/printers-contract — static half run, PASS.
  • quickshell/sharing-contract — static half run, PASS.
  • quickshell/gnome-handoff-contract — RUN END TO END, PASS (source-only): "ok (12 handoffs checked against 39 pages)" with network and wifi now in OWNED.
  • quickshell/health-ui-contract — static half run, PASS.
  • PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract — PASS.
  • setup/readme-contract — RUN END TO END, PASS.
  • bash -n on every changed contract; every embedded python3 heredoc compiled separately with py_compile.

Still open before the run

  • Connectivity.qml:204 names an enum member that does not exist. ConnectionFailReason.Authentication is not a member; the real ones are NoSecrets, Unknown, WifiAuthTimeout, WifiClientDisconnected, WifiClientFailed, WifiNetworkLost. QML resolves it to undefined, the switch arm never matches, and a wrong password is reported with the fallthrough text. NoSecrets is almost certainly the intended member. connectivity-contract's new enum check fails on exactly this line and passes on a copy with it corrected, so the check is confirmed working and the fix is a one-word edit in a service file this phase's contract owner does not own. This is the one item blocking a clean run.
  • The enum check is scoped to Connectivity.qml. The same class of bug — a plausible enum member that does not exist, resolving to undefined in silence — can live anywhere that imports Quickshell.Networking, including modules/quicksettings/WifiList.qml and the bar's status cluster. Widening the sweep to every QML file that imports the module is a small change and the obvious next one. Owner: whoever holds connectivity-contract next.
  • network-tools-contract cannot see the libnm branch. That is a deliberate trade, but it means the branch that actually runs on this machine (NM 1.56, bindings present) is only ever checked by reading. A machine without PyGObject would exercise the fallback for real; nothing here has one.
  • Two contracts still pin prose. firewall-contract requires the add flow to say "permanent" and "ask for your password", and sharing-contract requires "never passes through Panama", "install it and this becomes a switch" and "does not install software". The spec names all five phrases, so they are pinned deliberately, but they are the needles a copy edit will trip.
  • printers-contract pins the option vocabulary exactlymedia ∈ {Letter, A4, Legal}, sides ∈ {one-sided, two-sided-long-edge, two-sided-short-edge}. Adding a third settable option is meant to be a deliberate act that updates this contract, but it will read as a surprise the first time somebody tries.
  • connectivity-contract's live half is unchanged and still starts a harness beside the running session, so it wants the same quiet moment it always did. PANAMA_CONNECTIVITY_STATIC_ONLY=1 runs the new source-only half alone.
  • Run order for this phase: the source-only contracts first (gnome-handoff-contract, PANAMA_NETWORK_STATIC_ONLY=1 network-tools-contract, PANAMA_CONNECTIVITY_STATIC_ONLY=1 connectivity-contract, PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract, setup/readme-contract), then network-tools-contract in full — it is hermetic, so it can run at any time — then the read-only system contracts (firewall-contract, printers-contract, sharing-contract), then the harness contracts (connectivity-contract, health-ui-contract, settings-search-contract), and settings-pages-contract last, as before.

Docs updated in the same wave

  • services/SettingsSearch.qml — the three "Managed by GNOME Settings" connectivity entries are gone, and with them the duplicate Printers entry that routed to connectivity rather than to the Printers page. Ten entries replace them, all routing to connectivity: Wi-Fi, Bluetooth, VPN, Import a VPN, Hotspot, Airplane mode, Network proxy, IP address, Forget a Wi-Fi network, Enterprise Wi-Fi. Wi-Fi and Bluetooth were rewritten rather than deleted: the spec lists eight additions, none of which contains the word "Bluetooth", so deleting the lying entry outright would have made a switch the page has unreachable by search.
  • Checked against settings-search-contract's pinned query list: none of its 21 ranked queries, nor the nine leaf-routing queries, matches any new or changed entry, so no pinned top result moves. connectivity is a leaf in SettingsRoutes, so the routing sweep holds.
  • No settings docs or launcher commands were regenerated: this phase added no schema keys, so there is nothing for panama-settings-docs or panama-settings-commands to pick up. Verified by reading the diff — every new setting here is system state (NetworkManager, firewalld, CUPS, gsettings), not a Panama preference.

Phase 10 (Applications, Storage & Snapshots) — append below

Spec: 2026-08-24-applications-storage-snapshots-redesign.md. Applications became an app manager (installed list with search, Flatpak uninstall, permission summaries, per-app autostart, jump chips, the panama apps catalog rendered natively, role dropdowns plus a single-type override). Storage grew a proportional breakdown bar and an itemized "Clean up, honestly" card, and lost the duplicate container-images row that opened a terminal. Snapshots made retention editable and moved the file browser into its own card, which fixes "browse from a collapsed volume card does nothing".

Three agents edited the tree concurrently. Everything below was reconciled against the landed files at the end of the phase rather than against the spec's pinned shapes.

New contracts (1)

quickshell/app-library-contract. The README count line moves 170 → 171; setup/readme-contract was run and passes ("171 contracts, as documented").

Run and passing

These four are hermetic or source-only, so they were run rather than deferred:

  • quickshell/app-library-contract — RUN END TO END, PASS. Safe to run because it never reaches the machine: env -i, a stub directory first on PATH, recording stubs for flatpak/rpm/pkexec and screaming stubs for dnf/yum/sudo/rpm-ostree/gio, HOME and every XDG directory inside the scratch tree, and a fixture catalog through PANAMA_EXTRAS_DIR. It asserts, before running the helper at all, that the helper names no binary by absolute path and that all six commands resolve inside the stub directory. What it pins:
    • Catalog agreement. setup/lib/extras-catalog's bash functions and the helper's Python parser are both run over the same fixture files and their answers compared entry by entry — id, label, kind — rather than either being read. The fixture exercises the format's corners: a labelled entry, an unlabelled Flathub id (label derived from the last dotted component), a bare dnf name, a |-labelled dnf name, comments, blank lines, and two indented continuation lines that must fold into the entry above.
    • Ids are the catalog line verbatim. flatpak:org.example.Bravo, not the stripped ref: the bare ref is in the refusal list, because handing that to install would be refused for every Flathub entry in the real catalog and the failure would read as "installing is broken".
    • Nothing off-catalog installs, and the empty command log is what proves the refusal came first. Off-catalog ids, an unknown category, a path escaping the catalog directory, a continuation line, and an entry that exists in the other category are all refused with nothing run.
    • Extensions ride with their entry and nothing else does (installing Echo reaches for Echo and its two plugins, and no other org.example.*).
    • Removal is flatpak-only, pinned three ways: an AST walk that fails if any command list in the helper pairs dnf/yum/rpm-ostree with a removal verb or invokes rpm for anything but a query; a second AST walk over uninstall itself; and a runtime check that no argument — including a dnf package name — produces a package-manager call.
    • Permission buckets from data: filesystems=host → "Full file system access" and filesystems=home → "Home folder" are checked against separate fixtures so the two cannot be conflated; devices=all → Camera, sockets=pulseaudio → Microphone, shared=network → Network; an unrecognized key must still appear in the output; and a sandboxed application must summarize strictly shorter than a permissive one, so the summary cannot be a constant.
  • quickshell/applications-settings-contract — RUN END TO END, PASS (source-only). The bun-extracted roles / matchesRole half survives B's rebuild verbatim and gained four fixtures, including the two shapes that once made Archives match nothing: categories arriving as a QML list, and as a comma-separated string.
  • quickshell/default-apps-contract — RUN END TO END, PASS. Already fixture-driven (its own XDG tree, stub xdg-mime/xdg-settings); the new half adds a fixture mime/globs2 and two desktop entries that declare MimeType, then pins search-types shape, that candidates are only installed applications, that a one-character query is not a search, that set-type writes exactly one type, and that an unknown type, an unknown application, and a type this system does not have are each refused with nothing reaching xdg-mime.
  • setup/readme-contract, quickshell/search-routing-contract, quickshell/settings-ownership-contract — RUN, PASS after the nine new search entries.

Extended contracts, new halves run in isolation (3)

disks-contract and snapshots-contract each keep a first half that reads the live machine (panama-disks snapshot, panama-snapshots snapshot), so neither was run end to end. The new halves were extracted into scratch scripts and run on their own, and both pass:

  • quickshell/disks-contract (new half run, PASS). It is hermetic: env -i, HOME and XDG_CACHE_HOME inside the scratch tree, the block tree from PANAMA_DISKS_LSBLK, and recording stubs for gio/flatpak/pkexec/dnf/podman.
    • Two proofs run before anything is cleaned: breakdown.path must be the fixture home, and the cache segment must be the 12 × 111111 bytes this contract wrote a moment earlier. Only then is clean cache allowed to run — which is what makes it safe to assert that the symlink planted inside the fixture cache was unlinked rather than followed, and that the file it points at outside the cache survived.
    • The arithmetic: home + applications + caches + system == usedBytes exactly, free == freeBytes, every segment non-negative, and exceedsUsed false on the fixture. The two ways the measurement can be wrong (complete === false, exceedsUsed === true) are pinned as things the page says out loud.
    • The anti-racket stance, pinned as an absence in the cleanup card's own string literals: no "running out", "recommended", "act now", "junk", "safe to remove", "you should", no exclamation marks, and no single button that clears everything. The extractor takes the tightest card whose own title is the cleanup one, and scans only double-quoted literals — QML is full of ! and none of it is shouting.
    • Each cleanable does its own one thing: gio trash --empty (never rm), flatpak uninstall --unused --noninteractive, and pkexec dnf clean packages — never clean all, never a removal. An AST walk pins that this is the only package-manager command in the whole helper.
    • The unused-runtime size is borrowed, not recomputed: with PANAMA_APPLICATIONS_HELPER pointed at a recording stub, the row must report what that stub said (780000) and the stub must have been asked. Two ideas of "unused" would show one number and free another.
    • backing_device reading findmnt's SOURCE rather than st_dev is pinned with the reason: btrfs gives every subvolume its own device number, and the first version of the breakdown lost the system-wide flatpak installation to exactly that.
  • quickshell/snapshots-contract (new half run, PASS). Retention is exercised against a recording snapper stub under env -i, after asserting snapper resolves inside the stub directory — the only way to run a write verb without changing how this machine keeps its snapshots.
    • Refusals here are read from the JSON error field, not an exit code: this helper answers a refusal with fresh state and an error in it. Whether a refusal happened first is read from the absence of a set-config in the log, because the helper reads the configuration list on its way back out either way.
    • Ceiling pinned at 50 in both places — the helper's RETENTION_LIMIT and the service's retentionMax — because a dropdown offering a value the helper refuses fails after the user has already chosen.
    • The browser-card fix is pinned structurally: a brace-aware scan (strings and comments skipped) walks the QML ancestry of Snapshots.browseEntries and fails if the enclosing card is a delegate of the per-volume Repeater. This was checked against the OLD page first, where it correctly fails, so it is not a check that passes on anything.

Docs updated in the same wave

  • services/SettingsSearch.qml — nine entries added, all routing to leaves that exist in SettingsRoutes: Installed applications, Uninstall an application, Application permissions, Install applications, Autostart, File associationsapplications; Clean up storage, Application cachesstorage; Snapshot retentionsnapshots. Checked by evaluating the array: 154 entries, no duplicate labels, every page a real leaf, and none of settings-search-contract's 21 ranked queries or 9 leaf-routing queries matches a new entry, so no pinned top result moves. (The first draft of the Snapshot retention detail said "each volume keeps", which put it in the result set for the pinned volume query; reworded.)
  • No settings docs regenerated: this phase adds no schema keys. Every new setting is system state (flatpak, dnf, snapper, xdg-mime), not a Panama preference — verified by reading the diff.

Still open before the run

  • disks-contract and snapshots-contract have not been run end to end. Their first halves read the live machine, so they want the same quiet moment the other system contracts do. Nothing in the new halves depends on that order.
  • settings-search-contract has not been run: it starts a Quickshell harness. The nine new entries were checked statically as described above, but the schema-label sweep and the ranked queries need the live harness.
  • settings-pages-contract, settings-docs-contract, settings-jump-contract and settings-buttons-contract were not run against the three rebuilt pages. ApplicationsPage grew four new components (InstalledAppRow, FileTypePicker, SettingsChip, StorageBreakdownBar); the qmldir registration of the first three is pinned by applications-settings-contract, but nothing here has loaded the QML.
  • app-library-contract pins the fixture seam PANAMA_EXTRAS_DIR and the permission bucket wording ("Camera", "Microphone", "Full file system access", "Home folder", "Network", "Devices"). Both match the landed helper; changing either is meant to be a deliberate act that updates this contract, and will read as a surprise the first time somebody tries.
  • Run order for this phase: the hermetic and source-only ones first (app-library-contract, applications-settings-contract, default-apps-contract, search-routing-contract, settings-ownership-contract, setup/readme-contract), then the read-only system ones (disks-contract, snapshots-contract, containers-contract), then the harness ones (settings-search-contract, health-ui-contract), and settings-pages-contract last, as before.

Phase 11 (Users & Accounts) — append below

Spec: 2026-08-24-users-accounts-redesign.md. Users grew a profile hero with a stock-avatar gallery and a Remove, a password flow with a strength meter, a live-validating add-user form, a keep-files deletion choice, and per-user management (type / reset password / unlock). Fingerprint enrollment stopped handing people to GNOME's Users panel and became native — fprintd's EnrollStart driven from panama-fingerprint, streaming one JSON line per touch — and the card learned to appear in the state that needs acting on rather than only when a reader is attached. Online Accounts gained native Nextcloud and IMAP forms, confirmed removal, and an availability that no longer collapses on a failed write.

Three agents edited the tree concurrently. Everything below was reconciled against the landed files at the end of the phase, not against the spec's pinned shapes: three of the needles moved during the phase (the card-visibility expression went into the service as cardVisible, the add-user field became LiveFieldRow, and the fingerprint helper became Python) and each is pinned where it actually landed.

New contracts (1)

quickshell/online-accounts-contract. The README count line moves 171 → 172; setup/readme-contract was run and passes ("172 contracts, as documented").

Run and passing

  • quickshell/fingerprint-contract — RUN END TO END, PASS. The whole file was reworked, because two of its needles inverted. Hermetic: env -i, a stub directory first on PATH (asserted, before the helper runs, that fprintd-list, authselect, sudo and pkexec all resolve inside it), both bus addresses pointed at sockets that do not exist, PANAMA_PATH pointed at a recording panama-sudo that runs nothing, and enrollment replayed from PANAMA_FINGERPRINT_FIXTURE with the calls it stood in for landing in PANAMA_FINGERPRINT_LOG. What it pins:
    • The inverted needle. openGnomePanel("system", "users") is now a failure rather than a requirement, checked across every settings page. gnome-handoff-contract lost its UsersPage.qml:system-users exception at the same time; this is the other half of that, said from the page's side.
    • The stuck state, from the data. With the stub fprintd reporting no devices and the stub authselect reporting with-fingerprint, status must still answer unlockFeatureEnabled: true — the combination that used to be unrepresentable, because the feature was only read when fprintd answered. This machine is in that state, which is how it was found.
    • Card visibility followed one level of indirection. The expression moved into services/Fingerprint.qml as cardVisible: readerPresent || unlockFeatureEnabled; the contract accepts either the page spelling it out or the page binding to a service property that does, and resolves the second rather than forbidding it.
    • The enrollment stream as a stream. A five-stage fixture with a retry in the middle produces nine lines; every one must be a JSON object on its own, the counter monotonic and inside 0..total, total constant, and the retried touch must not have advanced the counter — a panel that counts a failed touch promises a finish that never arrives. The terminal line says which way it ended.
    • Claim and Release bracket everything, read from the call log: first call Claim, last call Release, EnrollStart after the claim — including on a failed enrollment, because a reader left claimed refuses the next attempt with "busy with something else" forever.
    • A terminal failure is reported in words, not in fprintd's vocabulary (enroll-duplicate → "That finger is already enrolled").
    • Nonsense is refused before the reader is touched: four bad finger names against both enroll and remove, each of which must leave the call log empty.
    • remove deletes the finger it was asked about, and remove-all uses DeleteEnrolledFingers2 with no arguments — its predecessor took a user name, which is how a typo deletes somebody else's prints.
    • With no fixture and no bus it still answers in its own shape: the page reads this stream line by line, and a traceback leaves an enrollment panel open forever.
    • The stated reason survived the rewrite to Python: the escalation must go through panama-sudo --reason …, and a bare sudo/pkexec in the argv log is a failure.
    • Finger vocabulary: a single id elsewhere is a default choice and is allowed; two in one file is a copy of the list, and the copy is what falls behind.
  • quickshell/user-accounts-contract — RUN END TO END, PASS. Its snapshot half still reads the live account service, which is read-only; every verb that would change something is pinned from the source and never invoked (set-icon gib "" really does clear the avatar). New halves:
    • reset-password carries no password material, by AST: it must call SetPasswordMode, must resolve its mode argument to 1 (through the named constant, so the meaning survives), must not call SetPassword, and its body — docstring excluded, because the docstring is allowed to say the word — must not mention stdin, openssl, crypt or passwd.
    • keep-files end to end, as three separate links, because any one of them can invert on its own: the helper derives DeleteUser's boolean from its own argument (a literal there is the bug), the service's ternary is checked against the polarity of its own parameter name (Panama flipped this once already, removeFileskeepFiles), and the page passes a variable rather than a constant. Both vocabularies are exercised — keep/remove and the legacy keep-files/remove-files — by reading which refusal comes back, so a vocabulary that stopped being understood shows up as the wrong message rather than as no message.
    • removeIcon sends an empty path, and set_icon recognizes it rather than handing "" to GdkPixbuf, which would fail and leave the avatar.
    • The live-validation regression, two ways. Structurally: the property the form's user-name test reads must be assigned from a per-keystroke handler, and the component that assigns it must not be TextFieldRow. Bluntly: the page carries no TextFieldRow at all. The page's regex is compared character for character against the helper's USERNAME, and the 31-cap must appear in the page's own text — a form that accepts a name accountsservice then refuses has no way to say why.
    • The last administrator is refused helper-side too, not only by the page that offers the control.
    • Stock avatars: shape read from a real run ({name, path}, absolute paths, empty list legitimate).
  • quickshell/gnome-handoff-contract — RUN, PASS (9 handoffs, 39 pages). The UsersPage.qml:system-users exception is deleted; the OnlineAccountsPage.qml:online-accounts one stays with its reason rewritten to name OAuth specifically — libgoa-backend ships without a GIR binding, so the provider's own dialog is the only way to obtain a token, and Nextcloud and IMAP are added on the page itself.
  • setup/readme-contract — RUN, PASS after the count line moved.

Run with one half relaxed (1)

  • quickshell/online-accounts-contract — everything passes except six input-validation cases, which are pinned as the spec asks and which the landed panama-accounts does not yet satisfy. See "Still open" below. Run with those six removed, the contract passes end to end. It is hermetic: env -i, a stub directory first on PATH (gdbus/busctl/dbus-send/ gnome-control-center all resolve there, asserted before running), a gi stand-in on PYTHONPATH whose require_version always raises — so if the fixture seam were ever removed the contract would stop working rather than quietly start editing the session's accounts — and both bus addresses pointed at sockets that do not exist. Accounts come from PANAMA_ACCOUNTS_FIXTURE; what each verb would have done lands in PANAMA_ACCOUNTS_LOG. What it pins:
    • Availability is not an error, at the definition and at every assignment: no line that decides available may mention a write error, and the service must keep at least two error strings, or "GOA did not answer" and "that one change did not happen" are the same fact. This is the regression where one refused toggle replaced four working accounts with "not available".
    • The same thing from the data: a refused add must come back with the error and the full account list, because the page draws its list from the same answer that carries the message.
    • The password on stdin, proved rather than assumed. The log records the password's length, never the thing itself, so the sentinel can be checked for everywhere it might have gone (stdout, stderr, the log, the scratch home) while passwordBytes proves it was read at all rather than dropped.
    • The listing cannot carry a credential, read from describe's own dict keys and from the GOA properties it reads, so a password property added later is caught at the source rather than after it reaches the page.
    • An added account is proved, not assumed: call_add_account_sync must be followed by call_ensure_credentials_sync, and verify must be able to call_remove_sync — GOA does not check what it is handed, so without that step a mistyped password produces an account that exists, looks correct, and never syncs, which is the exact failure this page exists to explain.
    • Provider types: owncloud (the fork kept the id) and imap_smtp. A provider type GOA does not know is not an error — AddAccount is simply never offered it.
    • Both names for the listing (list and snapshot) answer the same thing, and reading the list changes nothing.
    • Removal carries the path intact — removing the wrong account is unrecoverable and looks like a success — and is confirmation-gated on the page, read from the object block around the call rather than from a confirming property declared anywhere in the file.

Docs updated in the same wave

  • services/SettingsSearch.qml — nine entries added, all routing to leaves that exist in SettingsRoutes: Fingerprint, Enroll a fingerprint, Delete a user, Account type, Reset a passwordusers; Nextcloud, Google account, Add a mail account, Remove an accountaccounts. Checked by evaluating the array: 163 entries, no duplicate labels, every page a real leaf, and none of settings-search-contract's 21 ranked queries or 9 leaf-routing queries changes its top result — the only new entry any of them touches at all is Fingerprint, which matches lock through "Unlock" and sits far below the existing first hit.
  • No settings docs regenerated: this phase adds no schema keys. Every new setting is system state (accountsservice, fprintd, authselect, GOA), not a Panama preference — verified by reading the diff.

Still open before the run

  • online-accounts-contract fails six validation cases against the landed helper, and they are left pinned rather than relaxed, because the spec asks for them and each produces an account that exists, looks right in the list, and never syncs. nextcloud_uris accepts anything with a host once https:// is prepended, and imap_account accepts any non-empty string as a server:

    • add-nextcloud accepts not a url, https://cloud.example.org; reboot, and https://cloud example org;
    • add-imap accepts imap example com, imap.example.com; reboot, and -imap.example.com as either server.

    A hostname check in nextcloud_uris and one applied to both hosts in imap_account closes all six. '', ftp://… and https:// (no host) are already refused, and every other assertion in the file passes.

  • settings-search-contract has not been run: it starts a Quickshell harness. The nine new entries were checked statically as described above.

  • settings-pages-contract, settings-docs-contract, settings-jump-contract and settings-buttons-contract were not run against the two rebuilt pages. Six new components landed (LiveFieldRow, SecretFieldRow, PasswordStrengthRow, StockAvatarPicker, FingerprintEnrollPanel, OnlineAccountRow) and are registered in qmldir, but nothing here has loaded the QML.

  • The enrollment cancel path is pinned only from the source. The canned fprintd has no main loop, so SIGTERM releasing the device is exercised by neither half; what is pinned is that the release happens in a finally and that a failed enrollment still releases.

  • user-accounts-contract's first half reads the live account service (panama-users snapshot, stock-avatars), so it wants the same quiet moment the other system contracts do, though nothing it does writes.

  • Seams pinned by name: PANAMA_FINGERPRINT_FIXTURE/_LOG, PANAMA_ACCOUNTS_FIXTURE/_LOG, and the log shapes ({method, arguments} and {verb, arguments, passwordBytes}). Changing either is meant to be a deliberate act that updates these contracts.

  • Run order for this phase: the hermetic ones first (fingerprint-contract, online-accounts-contract), then the source-only ones (gnome-handoff-contract, setup/readme-contract, settings-ownership-contract, search-routing-contract), then the read-only system one (user-accounts-contract), then the harness ones (settings-search-contract), and settings-pages-contract last, as before.

Phase 12 (Privacy & Security) — append below

Spec: 2026-08-24-privacy-security-redesign.md. Privacy stopped being a page that read three device rows and pointed at GNOME for everything else. The portal model went from three hardcoded devices to six tables — camera, microphone, screencast, remote-desktop, background, location — with screencast and remote-desktop revoke-only, because their stored value is a structured GVariant describing a whole session and a switch cannot rebuild one. Traces became native: a new panama-privacy empties the recent-files list and the thumbnail cache, and the trash row reuses Storage's existing cleanable rather than growing a second implementation. SSH Keys learned to make a key, with the passphrase typed at ssh-keygen over a pty. The duplicated Screen-lock card was deleted and replaced with a pointer.

Three agents edited the tree concurrently. Everything below was reconciled against the landed files at the end of the phase rather than against the spec's pinned shapes, and four needles moved during it: speakers left the permissions model, the set verb became true|false rather than allow|deny, the Privacy page's confirm state became confirmingItem (with confirmingRevoke and confirmingTrace beside it), and the passphrase turned out to travel through a helper of its own (type_at_keygen) rather than inside generate. Each is pinned where it actually landed.

New contracts (1)

quickshell/privacy-traces-contract. The README count line moves 172 → 173; setup/readme-contract was run and passes ("173 contracts, as documented").

Run and passing

  • quickshell/permissions-contract — RUN END TO END, PASS. Rewritten: the old version set and cleared a camera permission on the REAL portal store under a probe application id and put it back afterwards. It now runs under env -i against a recording busctl first on PATH (asserted to resolve inside the stub directory before anything that writes runs), with DBUS_SESSION_BUS_ADDRESS pointed at a socket that does not exist. What it pins:
    • No Set path for a structured table, three ways. By import — the helper is loaded as a module so the table lists are read at their real values rather than pattern-matched, because they are derived from a registry and a contract that understood one spelling of that would be pinning the spelling. By AST — SetPermission is named exactly once in the file, inside one function, which consults a named list containing camera; that list must not contain screencast or remote-desktop, and the function must not name them either. And at runtime — set screencast … is refused and the call log gains no SetPermission, which is the assertion that survives a refactor of both of the others.
    • Revoke reaches every remembered token. The fixture store gives one application two screencast sessions; the snapshot has to fold them into ONE row carrying grants: 2, and forget has to issue two DeletePermission calls. Dropping the first token and leaving the row looking unchanged is the failure this catches.
    • A path-shaped entry id from the store is dropped, not fed back in. The stub's List answers with "../../etc/passwd" among the tokens; it must not appear in any subsequent call.
    • Absence is still not failure: the stub answers No entry for microphone on stderr with a non-zero exit, and the snapshot must report an empty microphone table with error: "".
    • speakers is gone, pinned as an absence: no portal backend arbitrates speaker access, so the row was a permanent "nothing has asked" for a question nobody asks, and listing it made the page look wider than it is.
    • Permissions.devices survives as the camera/microphone compat view, and something still reads it — ApplicationsPage does, and the symptom of losing it there is an empty list rather than an error.
    • Validated table and application ids, each checked twice: refused with a reason, and having reached no call.
  • quickshell/privacy-traces-contract — RUN END TO END, PASS. New, and hermetic throughout: env -i with HOME, XDG_DATA_HOME and XDG_CACHE_HOME inside a scratch tree, and nothing that deletes runs until the helper has reported the fixture's own numbers back (9 × 33333 bytes of thumbnails, two recent entries). What it pins:
    • Clearing recents empties the file, never unlinks it, and what is left parses as XML with <xbel> as its root and no bookmarks. A truncation to zero bytes fails: GTK reads a zero-length file as corrupt and stops recording history, which looks identical to success.
    • Clearing thumbnails does not follow a link out. The fixture plants both traps — a symlink to a file that must survive and a symlinked directory that must be unlinked rather than descended — and separately points a whole scratch ~/.cache/thumbnails at a directory outside the home, which has to be refused in words.
    • The seam is not a way around the guard: PANAMA_PRIVACY_THUMBNAILS pointed outside the home is refused exactly as a symlink is, which is what makes the rest of this file a test rather than a way of disabling the check.
    • One trash implementation, said three ways: the page reaches Disks.clean/Disks.cleanables, the helper's code (docstrings excluded by AST, since they are allowed to say why) never mentions the trash, and neither does Traces.qml.
    • No urgency language in the Traces card, found by what the card is wired to rather than by its title, and read only from its string literals.
  • quickshell/ssh-keys-contract — RUN END TO END, PASS. Its read-only half is unchanged and still runs against the real configuration. The new half runs under env -i with HOME in a scratch tree and a recording ssh-keygen first on PATH, which logs its own /proc/$$/cmdline and /proc/$$/environ and then plays the two passphrase prompts. What it pins:
    • The passphrase never reaches argv or the environment, asserted against what the kernel would have shown any other process — and it DOES arrive down the terminal, twice, or ssh-keygen would have stalled at the second prompt. It is also absent from the JSON the page parses and from every file under the scratch tree.
    • By AST, for every function that carries it, not only generate: the pty write lives in type_at_keygen, and a rule that looked only at the caller would have missed the place the value actually goes. No list literal, no dict literal, no open/mkstemp/write_text.
    • The environment ssh-keygen runs in, read back out of /proc: SSH_ASKPASS_REQUIRE=never (this desktop sets prefer, which draws a graphical dialog and hangs the pty until the timeout — A found this the hard way) and LC_ALL=C, since the prompts are matched by their words.
    • Overwrite is refused before ssh-keygen runs, and the existing key is byte-identical afterwards.
    • Ten bad key names, each refused and each having reached no ssh-keygen, with a file outside the SSH directory left untouched and the scratch directory still holding exactly the two files it should.
    • An empty passphrase needs --no-passphrase, the flag exists, and nothing in the shell passes it.
    • fix-permissions restores 0600 and is confined: five bad names refused, and a 0644 file outside the directory still 0644.
    • Removal from the agent is reachable now — helper verb, service function, page call, all three — and ssh-add was never invoked by the hermetic half.
  • quickshell/secrets-contract — RUN END TO END, PASS. Existing pins kept. New: the Copy button is only ever Copy. The page used to run one confirmation state for the whole card and label the neighbouring button confirming ? "Cancel" : "Copy", so arming a Forget replaced the word "Copy" in the exact place the user had just learnt to find it — and the way out of a confirmation was to press the button that copies. Pinned from the button block: its label is the literal "Copy", its handler does not read confirming, and no button both copies and forgets. Also that the copy confirmation is tied to the row it happened on (Keyring.copiedPath === …itemPath) rather than being one card-wide flag.
  • quickshell/gnome-handoff-contract — RUN, PASS (8 handoffs against 39 pages). privacy joins OWNED, with the reason: the card that carried the door was headed "Owned by Fedora" and explained that GNOME's file-history switches would not take effect in a Hyprland session anyway. Plus the inverse, said from the page's side — PrivacyPage.qml contains no openGnomePanel at all — because the generic loop can only fail on a door that exists.
  • quickshell/settings-ownership-contract — RUN, PASS. The lockMinutes/lockOnSleep mirrors onto privacy are gone from the expected table and from the README loop; modules/settings/README.md lost the two mirror rows with them. That was the one mirror in the table nobody had asked for: Privacy carried a whole second Screen-lock card, so one preference had two sliders.
  • setup/readme-contract — RUN, PASS ("173 contracts, as documented").
  • Also run and passing, unchanged by this phase but touching the two rebuilt pages: qmldir-registration-contract (185 components, including B's new PrivacyLiveTile and SectionLabel), search-routing-contract (144 routed settings), settings-nav-contract, settings-buttons-contract, settings-docs-contract (172 settings documented — the schema did not change, so no docs regeneration was needed), settings-hardcoded-values-contract, settings-jump-contract.

Deferred, and why

  • quickshell/lock-screen-settings-contract — NOT RUN. Its second half daemonizes a Quickshell preview harness and counts hyprlock processes, which is exactly the kind of thing that should not happen underneath a live desktop session. Its first half was edited and is source-only: the "Screen lock" card must not exist on Privacy, lockMinutes/lockMinutesBattery/lockOnSleep must each be bound on Power and on Privacy nowhere, and Privacy must point at Power & Lock rather than silently dropping the card. All five were verified by hand against the landed PrivacyPage.qml and PowerPage.qml; the harness half was not exercised.
  • quickshell/settings-search-contract — NOT RUN: it starts a Quickshell harness. The nine new entries were checked statically — parsed out of SettingsSearch.qml and confirmed to route to privacy and ssh-keys, both of which settings-nav-contract confirms are leaves.
  • quickshell/settings-pages-contract and settings-write-sweep-contract — NOT RUN: both load the QML. Neither page has been rendered by anything in this phase.
  • Traces.qml is pinned but never loaded. Its seam (PANAMA_PRIVACY_HELPER) and its lack of a trash path are checked from the source; no contract here constructs the singleton.
  • A deliberate duplicate label in search. "Application permissions" now appears twice — once routing to Applications (what a Flatpak's sandbox exposes) and once to Privacy (what the portal recorded). They are different questions that share a name, both cards are titled that, and the dedupe key includes the page, so both survive. Worth a look on the rendered sidebar.
  • Seams pinned by name: PANAMA_PRIVACY_HELPER, PANAMA_PERMISSIONS_HELPER, PANAMA_SSH_KEYS_HELPER, PANAMA_PRIVACY_RECENTS, PANAMA_PRIVACY_THUMBNAILS. Both privacy seams re-confine against HOME, so a hermetic run points HOME at a scratch tree rather than pointing the seams out of it; panama-ssh-keys reads Path.home(), so HOME alone redirects it.
  • Run order for this phase: the hermetic ones first (permissions-contract, privacy-traces-contract, ssh-keys-contract), then the source-only ones (gnome-handoff-contract, settings-ownership-contract, setup/readme-contract, search-routing-contract), then the read-only system one (secrets-contract, which lists the live keyring), then the harness ones (settings-search-contract, lock-screen-settings-contract), and settings-pages-contract last, as before.

Phase 13 (Power & Lock) — append below

Spec: 2026-08-24-power-lock-redesign.md. The Power page stopped being four cards that each said a true thing and together said nothing. Two idle cards holding the same three concepts became one, with a timeline drawn above the sliders and the ordering warning rendered on it rather than appearing as a fifth card. The power button became adjustable without root — logind keeps HandlePowerKey=ignore, and the compositor bind branches on a new powerButtonAction preference. The lid's open stopped clobbering the panel's configuration. Battery grew health, and the idle card grew a list of what is holding the machine awake.

Three agents edited the tree concurrently; everything below was reconciled against the landed files rather than against the spec's pinned shapes.

New contracts (1)

quickshell/power-page-contract. The README count line moves 173 → 174; setup/readme-contract was run and passes ("174 contracts, as documented").

The lid emission fix went into the EXISTING setup/lid-contract as a fifth section rather than into a file of its own. It drives the same helper the other four sections drive, through the same stub directory, and splitting it out would have meant a second copy of that scaffolding for no separation anyone benefits from.

Run and passing

  • setup/lid-contract — RUN END TO END, PASS. Hermetic throughout: a stubbed panama-hw answers the predicates, a stubbed hyprctl answers the monitor query from a fixture and records the rule instead of applying it, and XDG_CONFIG_HOME points at a scratch settings store. Nothing reached the compositor. The four existing sections are unchanged and still pass. The new one pins:
    • The full record survives the round trip. A fixture panel with mode, scale, transform, stored x/y, VRR, bit depth, colour profile and both SDR trims comes back as an hl.monitor rule carrying every one of them, with position built from the stored coordinates rather than "auto".
    • The old four-key emission fails it, verified by running the check against the pre-fix cmd_open before A's rewrite landed: eight findings, naming the position and every extended field it dropped.
    • The two failure directions stay different, which is the part worth a contract. A bad colour profile, VRR, bit depth or SDR value drops on its own and the geometry stands; a bad transform, scale or mode refuses the WHOLE record and the panel comes back on mode = "preferred", never half-honoured. Each of the three geometry fixtures was confirmed to be valid JSON differing from the good one in exactly one field, so none of them passes by failing to parse.
    • No stored coordinates means position = "auto", and a half-written position (an x with no y) is refused the same way monitors.lua refuses it.
    • null never reaches the rule — jq prints an absent key as the string "null", and a rule carrying one is the shape this whole fix exists to avoid.
    • It stays hyprctl eval, never keyword, so even a wrong rule dies at the next reload.
  • quickshell/battery-contract — RUN END TO END, PASS. Existing pins unchanged; still driven against fixture sysfs trees. New: health is read from energy_full/energy_full_design AND from the charge_ spelling (a driver that reports charge is not a machine without a battery), the cycle count is carried through, and — the half that matters — a battery whose firmware reports neither gets null, not a confident 100% and 0 cycles on hardware that never said. Checked four ways: the laptop fixture without the files, the desktop fixture with no battery at all, a zero design capacity that would otherwise divide by it, and the JSON staying parseable in every one of those cases.
  • quickshell/power-page-contract — RUN END TO END, PASS. Static apart from one stubbed run: panama-idle inhibitors against a fake busctl, which is the only part of the row nothing else covers. What it pins:
    • The power button cannot power the machine off. Said three ways, because this is the assertion the redesign turns on: no systemctl poweroff on the bind path (comments stripped first — keybinds.lua explains at length that it deliberately does not, and a contract must not fail over prose that agrees with it); the powermenu IPC handler in shell.qml runs no command of its own; and the menu still disarms its destructive entries, which is what makes powering off two presses.
    • Every option the schema offers is branched on, with the option values read OUT of the schema rather than restated — a contract listing the four would pass on the day a fifth was added and did nothing. Plus: the fallback arm opens the menu, so a hand-edited settings file cannot decide what the power key does, and the bind stays locked = true.
    • powerButtonAction carries no hypr block: it is read by keybinds.lua the way workspace rules are, and a hypr block would send Hyprland a keyword that does not exist.
    • One idle card, structurally. Not by title — the AC and battery sliders for each of the three timings must live inside the SAME SettingsCard block, sliced by brace depth. A rename cannot satisfy this and a second card cannot hide from it.
    • The inhibitor list is filtered, ordered, and honest about failure. A shutdown hold says nothing about whether the screen will blank and is dropped; sleep, idle and handle-lid-switch holds are kept, Panama's own lid inhibitor included; delay inhibitors sort below block ones, because a delay does not keep a machine awake; and a logind that cannot be reached exits non-zero WITHOUT printing [], so "could not look" never reads as "nothing is holding it". The ordering assertion was checked against a delay-first list to confirm it discriminates.
    • The page's own components count as the page. The file list is derived — PowerPage.qml plus every settings component it instantiates that no other settings page does — so a card lifted into a component of its own keeps being checked instead of quietly falling out of scope.
  • hypr/idle-config-contract, hypr/idle-defaults-contract, quickshell/powermenu-contract — RUN, PASS, unchanged. Verified rather than assumed: the inhibitors verb is additive (it adds no read_setting call, which is what idle-defaults-contract walks) and the powermenu's pre-select argument left logoutScript alone.
  • quickshell/search-routing-contract — RUN, PASS (145 routed settings, up from 144: powerButtonAction is the new one). Also RUN and passing: schema-hypr-shape-contract (77 mapped options), enum-hypr-map-contract (11 mapped enums), preference-schema-contract, settings-ownership-contract, setup/readme-contract.
  • Schema docs and Vicinae commands regenerated and passing. quickshell/scripts/panama-settings-docs and panama-settings-commands were both run without --check and their output committed: docs/settings.md goes 172 → 173 settings across 36 groups, with the new power group rendering as "Found on Power & Lock" — the group comment shape the spec asked C to verify, which comes from the "power": "power" route added to SettingsSearch.qml. settings-power's Vicinae keywords gained "pressing the power button", "hibernate" and "power profile". quickshell/settings-docs-contract — RUN, PASS (173 settings documented); quickshell/panama-commands-contract — RUN, PASS (77 commands).
  • Six search entries added: Hibernate, Power profile, Power button, Lid, Suspend, Sleep, all routing to power, plus the "power": "power" group route for the new schema group. Hibernate is the one worth having: it has no control at all, only an honest row saying why this machine will not do it, and a search that found nothing would read as the desktop having no opinion.

Deferred, and why

  • quickshell/settings-search-contract — NOT RUN: it daemonizes a Quickshell harness. The six new entries were checked statically and route to power, which settings-nav-contract confirms is a leaf.
  • quickshell/settings-pages-contract, settings-write-sweep-contract, qmldir-registration-contract, settings-buttons-contract, settings-nav-contract, settings-jump-contract, settings-hardcoded-values-contract — NOT RUN: they load the QML or drive the live settings window. The Power page was rebuilt this phase and needs all of them at the end-of-redesign sweep.
  • quickshell/lock-screen-settings-contract — NOT RUN (same harness reason as phase 12); its source-only half still expects lockMinutes, lockMinutesBattery and lockOnSleep bound on Power, which the rebuilt page keeps.
  • One thing the two agents disagree about, left as it landed. A's IdleLock exposes inhibitorsKnown beside inhibitors, so a logind that could not be reached is distinguishable from one holding nothing — the helper's own contract pins that distinction, and the verb exits non-zero without printing [] precisely so the caller can tell. B's page does not read it: an empty list renders as "Nothing — no application holds a wake lock" whether the probe came back empty or never came back, which the page argues in a comment is also the honest thing to say before the first reading lands. That is defensible for the first-read case and wrong for the failure case, and it is a copy decision rather than a wiring one, so it is reported rather than pinned. Worth a look on the rendered page.
  • The whole live half of the power button. Nothing here presses it. The contract proves the bind branches and that no branch powers off; whether the compositor picks the new bind up needs a hyprctl reload, which this phase deliberately did not run.
  • Hibernate is dormant on this machine. CanHibernate answers no under zram swap, so the gated row is pinned by its wiring rather than by having been seen.
  • Battery health is dormant too, for the same reason the rest of the battery surface is: this is a desktop. Every health assertion runs against a fixture sysfs tree.
  • Run order for this phase: the hermetic ones first (lid-contract, battery-contract, power-page-contract, idle-config-contract, idle-defaults-contract), then the source-only ones (powermenu-contract, search-routing-contract, schema-hypr-shape-contract, preference-schema-contract, settings-docs-contract, readme-contract), then the harness ones last.

Phase 14 (Accessibility) — append below

Spec: 2026-08-24-accessibility-redesign.md. The Accessibility page stopped being six cards of controls sorted by mechanism plus one card that apologised, and became five sorted by what is being accommodated: Vision, Motion, Hearing, Keyboard & pointer, Screen reader. Behind them, the four things an accessibility page exists for and this one did not have: the magnifier gained keybinds routed through the shell, Reduce motion became true of the shell's own bar, dock, panels and OSD rather than only of the compositor, a visual alternative to the notification bell arrived, and Orca became a process that is reported as running or not instead of a button that launched it and forgot. Invisible and largest: the nine shared row primitives gained screen-reader names, roles, Tab stops and key handling, which reaches all fourteen categories at once.

Three agents edited the tree concurrently; everything below was reconciled against the landed files rather than against the spec's pinned shapes.

New contracts (1)

quickshell/accessibility-contract. The README count line moves 174 → 175; setup/readme-contract was run and passes ("175 contracts, as documented").

Entirely static, on purpose. Every failure it guards against is one a live run would report as working: hyprctl keyword exits 0 on a Lua-configured Hyprland while refusing the write, a gsettings screen-reader-enabled write succeeds and starts nothing, and a row with no Accessible.name looks and behaves exactly like one that has it. Nothing here starts a shell, applies a zoom, starts or stops Orca, or writes a gsettings key.

What it pins, in the order the file argues them:

  • The zoom path commits through the preference and never touches hyprctl. Asserted over the PATH rather than over stepZoom's own text: the slice is stepZoom plus every function in Accessibility.qml it hands work to (showZoomOsd, zoomLabel), collected transitively, so splitting the OSD call into a helper moves the assertions with it rather than out from under them. Within that path: no hyprctl, no keyword, a SystemSettings.commitPreference call, an OsdState post, and clamp bounds that either read PreferenceSchema.spec or match the schema's own 1.05.0. The accessibility IPC handler in shell.qml is checked the same way.
  • The three binds, matched by target and verb rather than by chord. SUPER+= was already "Reset split", so which free chords the zoom keys take was a decision made against keybinds.lua rather than against the mock — a contract naming SUPER+= would have been wrong on the day it was written. A separate check fails on any chord bound twice, which is the actual hazard: Hyprland takes the last bind and the earlier action stops working silently.
  • The visual bell is one-shot. No loops: Animation.Infinite, no loops: -1, no Timer with repeat: true anywhere behind it, and it reads visualAlerts.
  • The flash follows bell eligibility EXCEPT the event-sounds gate. The signal name is read out of VisualBell.qml's Connections { target: Notifs } rather than guessed, which also pins that the two are really wired. Two structural checks then run over Notifs.qml: no SoundFeedback.eventSounds early return may stand between the enclosing function's first line and the emit, and the emit must sit on a path that DOES carry the shared gates (appRule(...).sound, low urgency, suppress-sound) — otherwise the flash would not be the bell seen, but a second louder notifier ignoring every per-application rule somebody set. A third check fails if the file stops explaining the exception, since an undocumented exception is one the next reader helpfully removes.
  • Orca is a process, never a gsettings key. screen-reader-enabled is grepped for repo-wide across the shell's QML and JS, comment-stripped first. Plus: orcaRunning exists, something pgreps, and the probe is not on a repeating Timer — a poll that runs all session to answer a question nobody asked is a cost with no reader.
  • The nine primitives. Accessible.name, Accessible.role and activeFocusOnTab in each of SliderRow, ToggleRow, SwitchRow, ActionRow, ChoiceRow, SegmentRow, SettingRow, OptionPickerRow, PickerRow, resolved through the QML inheritance chain (a name inherited from SettingRow is a name the row really has) — with one exception that is NOT inheritable: a file drawing its own MouseArea/SettingsToggle/ SettingsButton/ValueSlider must declare its own activeFocusOnTab, because SettingRow is a Tab stop only when activatable, which a ToggleRow is not. Then: Space/Enter activation on the four rows that activate, Left/Right on the two that hold a range, a focus indicator gated on activeFocus, and — the visual-at-rest promise — no focus border painted unconditionally, since these rows are used by every page.
  • The page, per the approved mock. The five card titles, the five retired ones absent, dimStrength gated on dimInactive, the mono-audio row present and marked not-yet with somewhere to go meanwhile, the Keyboard jump going through openSettings() rather than assigning settingsPage by hand, and no Process on the page or its own components.
  • The honesty box is a statement, not an alarm. It must name Hyprland (not "Wayland"), say the absence was probed rather than assumed, and explain why the GNOME switches are inert here — and it must NOT be painted in Theme.danger/Theme.warn or use the words Warning, Error, Unsupported, unfortunately, sorry, Broken. An accessibility page is the last page that can afford to open with an alarm.
  • The dead zero label, swept across every settings page rather than only this one. SliderRow.display() substitutes zeroLabel at exactly 0, so a row setting one for a setting whose schema minimum is above 0 is copy that can never appear — which is what zeroLabel: "Off" was doing under a magnifier whose minimum IS 1.0. Any page reintroducing that class of bug now fails.
  • Reduce motion is true of the shell: Theme.motionEnabled reads Settings.animationsEnabled, and all four duration tokens collapse to 0.
  • The cross-goal collision, which is silent. VisualBell must not time its flash with a Theme.dur* token: those now collapse to 0 under Reduce motion, so a flash timed with Theme.durFast would be instantaneous and therefore invisible — switching on Reduce motion would silently switch OFF Visual alerts, for somebody quite likely to want both. A landed and pinned.

Reconciled

  • quickshell/notification-app-rules-contract — RUN, PASS after a fix. It broke on the visual-alerts refactor: the bell's shared gates moved out of playBell into the new bellWouldRing predicate, and the contract read only playBell's literal body, so it reported that the per-application sound switch and the effective-urgency check had been dropped when both had merely moved one call up. Now asserted over the bell's DECISION PATH (playBell + bellWouldRing), plus a new assertion that playBell actually consults the predicate — without which every gate would sit in a function nothing runs, which reads exactly like a passing contract.
  • quickshell/osd-model-contract — RUN, PASS. Gained the magnifier's OSD kind: iconFor('zoom', 0.4) is zoom-in-symbolic and iconFor('zoom', 0) is zoom-original-symbolic, since an empty bar is 1.00 ×, which is off rather than barely magnified. Without a mapping the kind falls through to its own name and the OSD draws the generic fallback glyph — for the one shortcut whose whole job is telling somebody who cannot read the screen what the magnification now is.
  • No contract pinned the Theme duration literals. Checked by grep across tests/ for durFast/durNormal/durSlow/durDockReveal and motionEnabled: zero hits before the change, so gating them broke nothing. accessibility-contract now pins the gating itself, which is the first time those tokens have been pinned anywhere.
  • quickshell/settings-ownership-contract — RUN, PASS unchanged. Its duplicate table expects exactly animationsEnabled, cursorInactiveTimeout, cursorSize and inactiveOpacity to appear on two pages, and the rebuilt page keeps all four, so the mirror set did not move.

Seven search entries added

Magnifier zoom, Zoom in and out, Reduce motion, Visual alerts, Screen reader, Orca, Sticky keys — all routing to accessibility. The schema already covers the switches by their own labels, so these are only the words people arrive with that no label uses: the verb rather than the noun ("zoom in", not "Magnifier"), the application's name rather than its category ("Orca", not "Screen reader"), and the schema's own wording gap ("Reduce motion", where the label reads "Animations"; "Visual alerts", where it reads "Flash the screen for notifications").

High contrast was deliberately NOT added, and the contract fails if it is: it is already a schema label, and the index covers every schema label, so a second copy would list the same setting twice in one result. The contract also fails if any of these entries routes anywhere but accessibility.

Schema docs and launcher commands regenerated

quickshell/scripts/panama-settings-docs and panama-settings-commands both run without --check and their output committed. docs/settings.md goes 173 → 174 settings across 36 groups: visualAlerts renders in the accessibility group, and magnifierFactor picks up its new × unit in both the default column ("1.0 ×") and the detail ("1.00 × is off"). settings-accessibility's Vicinae keywords gained "flash the screen for notifications", "magnifier zoom", "zoom in and out", "reduce motion" and "visual alerts". quickshell/settings-docs-contract — RUN, PASS (174 settings documented); quickshell/panama-commands-contract — RUN, PASS (77 commands; the settings generator writes 38 of them).

Deferred, and why

  • quickshell/settings-pages-contract, settings-write-sweep-contract, settings-search-contract, settings-buttons-contract (live half), settings-preferences-contract, settings-commit-reset-contract, settings-system-contract, settings-hyprland-write-contract — NOT RUN: they daemonize a Quickshell harness or drive the live settings window. The Accessibility page was rebuilt this phase, SettingsNote.qml is new, and all nine shared row primitives changed, so this set is the end-of-redesign sweep and matters more here than it did in any previous phase — a broken SliderRow takes every settings page down, and only these would see it. qmldir-registration-contract (static) was run and passes, so SettingsNote at least resolves.
  • The whole live half of the zoom keybinds. Nothing here presses a key. The contract proves the binds exist, name the IPC verb, sit on unoccupied chords, and that the path behind them commits through the preference; whether Hyprland picks the new binds up needs a hyprctl reload, which this phase deliberately did not run.
  • The flash has not been seen. VisualBell is pinned structurally — one animation, no loops, no repeating timer, gated on the preference, not timed with a motion-gated token — but nobody has watched a notification arrive with Visual alerts on. That is the one assertion here a screenshot would settle and a grep cannot.
  • Orca has not been started or stopped. The service is pinned as a process probe with on-demand polling; whether pgrep -x orca matches the installed Orca is A's verified claim in a comment (it calls set_process_name("orca"), which renames comm), not something this contract re-checks.
  • The screen reader has not read the page. Accessible.name, role and activeFocusOnTab are present in all nine files and the key handlers are there, but no AT-SPI client has walked the settings window to confirm the names come out in a useful order. That needs Orca running against a live shell.
  • quickshell/sound-page-contract fails, and it is not this work. Verified by stashing the whole working tree and running it against a clean checkout: it fails identically ("DictationPage does not report whether the speech server is installed"), so it was already red before this phase began.
  • Run order for this phase: the static ones first (accessibility-contract, osd-model-contract, preference-schema-contract, schema-hypr-shape-contract, settings-ownership-contract, search-routing-contract, ipc-targets-contract, settings-jump-contract, settings-docs-contract, readme-contract, all eight of tests/hypr/), then the sandboxed-harness ones, then the live settings window last.

Phase 15 (System) — the last one

Spec: 2026-08-24-system-redesign.md. System went from ten tabs to eight: Region & Language merged into Date, Time & Region — a date format and the clock that shows it are one subject, and splitting them meant changing how a date is written on a page that never showed one — and the Manual became a routable leaf with no tab, opened from About's Manual card, a deep link, or the search box rather than found by scanning a strip. With that, the settings redesign is complete: fourteen categories, thirty-eight leaves, every one of them rebuilt.

The bug list this phase carried was longer than the layout change. The hardcoded Quickshell version, the auto-download switch nested inside the Firmware card, CHECK_TITLES missing panama.updates, two Health actions with no target, RegionPage's stale "Open appearance" handoff, panama-updates' stale dnf-automatic comment, the backup colour gap, Restore without confirmation, and four pieces of dead code.

Three agents edited the tree concurrently; everything below was reconciled against the landed files rather than against the spec's pinned shapes, and every contract named here was run.

No new contracts (175 → 175)

Every pin fitted a file that already existed, so the suite did not grow. That was the judgement call this phase offered and it went the honest way: a system-pages-contract would have been a new file holding assertions that belong beside the ones they are variations of — the nav counts beside the nav taxonomy, the repair command beside the repair, the single-check verb beside the scan. setup/readme-contract — RUN, PASS ("175 contracts, as documented").

What each contract gained, and why

  • quickshell/settings-nav-contract — RUN, PASS (14 categories, 38 leaves of which 1 hidden, 3 retired ids). It learned the third leaf shape: SettingsRoutes.hiddenLeaves, which is how the manual stays addressable without a tab. A hidden leaf that is also a tab, or also a category, now fails — that overlap is the one the existing checks could not see, because the hidden half draws no row anywhere. Then: the System strip is pinned at exactly eight tabs in order (the number IS the point — it is the horizontal space one row has, and an eleventh subject needs a decision, not another entry), region is pinned to resolve specifically to datetime rather than merely to a leaf, RegionPage.qml must be gone, and the manual must be a leaf and must not be a tab. It also grew the check that would have caught the retired search entries: every page id in SettingsSearch — extra entries and group routes alike — must be a live leaf, with a distinct failure message when it is a retired id, since that is the case that still opens a window and lands somewhere else. Plus a table of the fifteen subjects the consolidation moved, each asserted to be findable by its own name on the tab that now owns it.
  • quickshell/settings-pages-contract — RUN, PASS (production PIDs preserved). Updates, DateTime, Containers and Manual joined the page-scaffold sweep; Containers is the one that was waiting for it, since its root was a bare Item and every convention the scaffold carries was hand-rolled there. The live routing list gained updates, datetime and containers, and the retired region id is now driven through the real IPC and asserted to land on datetime.
  • quickshell/updates-contract — RUN, PASS (2 dnf, 3 flatpak, 0 firmware). Three additions. Download sizes add up or are not offered: a total is present only when every pending item was priced, because a partial figure shown as the download understates it, and understating it is the direction that costs somebody money on a metered connection. A changelog is a read, proved by construction rather than by inspection: dnf5, flatpak, fwupdmgr, pkexec and systemctl are replaced with stubs that record their argv and answer nothing, and the recorded argv is then checked for mutating tokens — which also exercises the honest-absence path, the common case on a machine with third-party repositories. Names outside NAME_PATTERN must be refused before anything is launched. One application, by name: the per-app argv is asserted exactly (flatpak update -y --noninteractive <id>) against a stub, and an ID the last scan did not list must be refused without running flatpak at all.
  • quickshell/panama-doctor-contract — RUN, PASS. The CHECK_TITLES KeyError is pinned twice: as the invariant (every id in CHECK_ORDER has a title, asserted by importing the module) and as the path (a probe forced to raise must come back as a titled warning row rather than taking the report with it). Worth both — the bug needed the updates probe to fail, which it almost never does, and the containment that was supposed to catch it was the KeyError, raised inside the except handling the original failure. Then: the updates action's target: "updates", driven by a fixture cache with a security count; repairCommand asserted against REPAIR_COMMANDS itself rather than restated, so an argv change cannot leave the row describing the old one; and the check CHECK_ID verb — full snapshot envelope, exactly one check, summary arithmetic covering only that check, refusal of unauthored ids with no report and no filesystem mutation, and no second output mode. The fixture also gained XDG_CACHE_HOME, which was leaking: check_updates reads the Updates cache from the environment rather than through DoctorConfig, so the contract had been reading the real machine's pending updates.
  • quickshell/health-service-contract — RUN, PASS. The both-sides pin is derived from both files: every open/instructions target the doctor emits must appear in Health's settingsTargets/instructionTargets. This is the one where half a change does not degrade a row, it blanks the page — validAction false makes validCheck false makes consumeSnapshot reject the whole report. Live fixtures either side of it: a check targeting updates is accepted intact, and one targeting storage — a real page the list deliberately omits — is rejected, which is what makes the allow-list an allow-list. Also repairCommand surviving the projection (a new known key is dropped silently unless it is added, and the failure is invisible: the repair still works, only the sentence saying what it runs is gone), a check without one still accepted, refreshCheck asking for check <id> and never --json while leaving the other rows and recomputing the headline counts, an unknown id starting no process, and saveReport writing the same redacted projection to a named file.
  • quickshell/health-ui-contract — RUN, PASS. The Colour profiles handoff assertion is inverted rather than deleted (Displays owns colour profile, bit depth, SDR brightness and saturation per output, which is more than GNOME's panel can say in a session it does not manage); Digital wellbeing stays. The repair command must be shown, and must not be gated on lastRepair, repairingId or a working state — after the fact is not the same answer, because by then the decision is made. A per-row re-check and a save-report row must exist. The fixture grew a panama.updates check whose action is followed for real through requestHealthAction, landing on updates: the third piece of the chain the doctor and the service each pin one end of.
  • quickshell/settings-backup-contract — RUN, PASS. Named snapshots (fifteen rows reading 2026-08-24 11:03:07 are fifteen rows nobody can choose between), with the name reaching a filename — which is the oldest way a helper gets talked into writing outside its own directory. Seven hostile names, each of which must either fail or produce a file inside the backup directory. Delete gets the same confinement as restore in the other direction, and a worse failure mode: restore reading the wrong file overwrites settings, delete resolving the wrong name destroys something that is not a backup. Traversal, absolute paths, an escaping symlink, an already-deleted name and an empty name all refused. bytes per row, checked against stat.
  • quickshell/settings-backup-live-contract — RUN, PASS. The colour gap, both halves. layoutFromStoredDisplays must carry vrrMode, colorProfile, bitdepth, sdrBrightness, sdrSaturation and mirrorOf through, and must still accept a record written before they existed. layoutsEqual must detect a difference in each of them — this is the assertion the shipped behaviour fails, and the reason the restore silently did nothing: the seven-field comparison called a snapshot with different colour settings equal, so the restore took the early return and reported success while leaving HDR off. Plus the inverse for the two float fields: a value that came back one ulp different is the same value, or every restore reapplies the layout it already has. Two harness seams were added for this (layoutFor, layoutsMatch), because the colour half is invisible from handleRestoreOutput — geometry restores perfectly and success is reported either way.
  • quickshell/settings-sync-contract — RUN, PASS. The preview's changes must be {key, from, to} with both sides already text, a changeCount agreeing with what would be applied, a rendered list no longer than that count, and no side over 200 characters — with a bundle carrying a 4000- character weatherLocation proving the cap is real rather than incidental. Stringifying in the helper rather than in QML is what makes the cap enforceable at all. Observed on this machine: 115 changes, 40 rendered, absent values rendered as "not set".
  • quickshell/manual-contract — RUN, PASS (5 chapters). Titles now come from the files: the chapter list moved to ManualChapters.qml, which both the reader and About's card instantiate, and the contract fails on any label: beside a filename — the drift it is guarding against had already happened, under a comment claiming it could not. It also fails if either page names chapter files itself. In-app links: the handler must route panama://settings/<page> through ShellState.openSettings while still sending everything else out of the desktop, asserted by following the call (inline block or named function, brace-matched) rather than by reading one line, and every such link written in a chapter must name a real leaf. Plus the taxonomy check inverted: the manual must be a hidden leaf and must NOT be a System tab.
  • quickshell/gnome-handoff-contract — RUN, PASS (6 handoffs checked against 37 pages). Two panels joined OWNED: system region, because Date, Time & Region now offers a language picker, four per-category format dropdowns backed by the installed locales, and a live preview of what each renders; and color, because Displays has offered a colour profile per output for some time. Both are derived rather than hand-asserted, so the door cannot come back under a name nobody thought to list. Plus two inverses: RegionPage.qml must stay gone, and DateTimePage must not open a GNOME panel for something it now does itself.
  • quickshell/search-routing-contract — RUN, PASS (146 routed settings). Unchanged; run because SettingsSearch moved.

Search entries

Two moved: Language and Regional formats, from region to datetime. Fifteen added: the seven About facts nobody could search for (Hostname, Kernel version, Device model, Installed memory, Uptime, Serial number, BIOS version — none of them the label of a preference, all of them things people arrive looking for by name, and "Installed memory" rather than "Memory" because that one IS already a schema label on Bar); the two Sync verbs (Export settings, Import settings — "Carry settings to another machine" was the only entry, and nobody searches for a sentence); and six format subjects on the merged tab (Date format, Number format, Currency, Measurement units, Paper size, First day of the week) plus "Set the clock by hand".

Schema docs and launcher commands regenerated

quickshell/scripts/panama-settings-docs and panama-settings-commands both run without --check and their output committed. docs/settings.md stays at 174 settings across 36 groups — no preference was added this phase — but the datetime group is now "Found on System Date, Time & Region", which is the whole point of a generated document: the tab was renamed in one place.

The launcher went 38 generated commands to 37: settings-region was removed, and settings-manual survives because the generator learned about hiddenLeaves — a leaf with no tab is the one leaf that cannot be found by scanning, so it is the one that most needs a launcher entry. Keywords picked up the new search entries on their own: settings-about gained hostname, kernel version, device model, installed memory, uptime, serial number and BIOS version; settings-datetime gained language, regional formats and the six format subjects; settings-sync gained export and import.

quickshell/settings-docs-contract — RUN, PASS (174 settings documented). quickshell/panama-commands-contract — RUN, PASS (76 commands), after teaching its own leaf derivation about hiddenLeaves too: it counted 75 and found 76, which is the generator and the contract disagreeing about what a leaf is rather than a stale command. quickshell/qmldir-registration-contract — RUN, PASS (189 components), covering the two new ones, ManualChapters and FieldActionRow.

Deferred, and why

  • quickshell/settings-search-contract — NOT RUN. It daemonizes a Quickshell instance against the user's real XDG directories rather than a scratch set, and constructing SettingsSearch calls DesktopStyle.ensureStarted(), which replays application preferences to the live GTK configuration. Every case it pins was checked statically instead: none of the fifteen new labels equals or prefixes any of the twenty-one pinned queries, so none can outrank one, and every schema label is still reachable because the schema half of the index did not change. The new static page-id sweep in settings-nav-contract covers the failure this phase could actually have introduced.
  • Nobody has typed a locale into the four format dropdowns. The category round-trip is A's, through panama-locale; no localectl write was made from any contract here, and none should be.
  • The manual's in-app links have not been clicked. The handler is pinned structurally and every link a chapter writes is checked against the taxonomy, but no chapter currently writes one, so the routing has been proven correct and never exercised.
  • No backup has been restored against the real store, and no update has been applied. Both are pinned through stubs and scratch directories, which is the sanctioned path and the only one that should ever run here.