124 KiB
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 bysetup/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 addedphone-page-contract).- Phase 2 additions already written and passing when last run:
my-home-settings-contract(renamed fromhome-phone-settings-contract),phone-page-contract, extendedhome_assistant_bridge_test.pyandkdeconnect_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 isSettings.qml, which the theme work did not touch;Theme.qml's literals are covered bytheme-catalog-contractinstead.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 listsinput.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 bysetup/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'stabproperty 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 |
DockPinsEditor → DockPinsStrip: 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-app → panama-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 newbar/controlCenter/datetimegroups and the re-pointeddock/multitasking/edges/master/notices/focusroutes 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.mdand the 37settings-*launcher commands — regenerated bypanama-settings-docsandpanama-settings-commands; both--checkmodes clean and both generators verified idempotent.settings-desktopis deleted,settings-bar/-dock/-control-center/-tiling/-workspaces/-syncare new, anddock-add-appjoins 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, whyuse24Houris 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 waypanama testcollects (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), andlock-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-contractis untracked and belongs to the separatepanama updatedesign, not to this redesign. It is inside the 166.quickshell/dock-position-contracthas 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-contractpassed 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.qmlhas one stray indentation glitch at its firstRow(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-contractandswitcher-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 — anddisplays-contracthas 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()exposesdraggablealongsiderects/scale, and passescanvasRects'mirrorOf/mirroredthrough — it returnscanvasData.rects, not the solo-shrunktiles, which is what the new fixtures assert against. Confirmed in the landed component.- The solo hint is pinned as the prefix
One display connectedrather than the full sentence, so the em dash cannot break the grep. - The mirror badge is pinned as
MirrorsinDisplayArrangement.qml. soloFixture/mirrorFixturemutatefixtureService.monitorsand callresetDraft(), which is what the component's ownonMonitorsChangeddoes. Whether that ordering settles beforecanvasSnapshot()reads back is the one thing only a run can answer.
Still open before the run
— resolved: the detail now names color, VRR override, and mirroring, and the matching grep inPreferenceSchema.qml's staledisplaysdetail stringdisplays-contractwas updated in the same commit.displays-contractalso grepsDisplaysPage.qmlforselectedOutput,scalesForMode(,primaryFirstMonitors.map(andenabled: 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, particularlyprimaryFirstMonitors.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-contractneeds nothing. - Run order for the sweep:
display-layout-contractfirst (pure geometry, no compositor), thendisplay-transaction-contract(fake compositor onPATH), thendisplay-arrangement-contract, anddisplays-contractlast — it is the only one that drives the physical display, and it refuses to start from a scale that does not match whatmonitors.luaships.
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_FIXTUREandPANAMA_SOUND_DEFAULTS_FIXTUREare file paths the servicecats 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.qmlis 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.SoundDeviceListasksSoundDefaults.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 readsBluetooth device (AA:BB:CC:11:22:33)with parentheses.SoundDeviceListbriefly had its owntidyName()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, andgroupPagesgained"sound": "sound"so the new schema group self-indexes.- Top-level
README.md— contract count 166 → 169, recounted with the samefindsetup/readme-contractuses; that contract was run and passes.
Still open before the run
osd-helper-contractis written against apanama-osdthat 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(thepanama-idle/panama-lidspelling, notpanama-palette'sPANAMA_SETTINGSoverride), and that the blip's sound file can be overridden withPANAMA_OSD_BLIP_SOUND(matching the existingPANAMA_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 inrun_helperare the only lines that need changing. Reconcile before running.- The contract also now asserts
-lon 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 >= 0and.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 bycaptureTypesValidand 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), thenapplication-volume-contract, thenosd-helper-contract, andsound-page-contractlast — 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-contractconstructsSoundPage, whose microphone test and channel strip are one IPC-less click away frompw-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 fromSettingsShell's switch, sofocusresolved toFocusPage.qmlthe 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 routesdisplaysand the retireddesktopid only.quickshell/settings-ownership-contract— its duplicate-row scan and itsgroupPagesparse were replayed against the editedSettingsSearch.qmland still pass.setup/readme-contract— no contract file added or removed;findstill counts 169 and the README still claims 169.
Docs updated in the same wave
services/SettingsSearch.qml—groupPages"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 againstsettings-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-contractused toperl-graft a temporarynotificationAppRulesschema key into its copy ofPreferenceSchema.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 bothnotificationAppRulesandcriticalBreaksThroughare present. NotificationAppRulesHarness.qml's fake notification now spells outurgency,expireTimeoutandhints. 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
breakthroughfixture drivesSettings.criticalBreaksThroughby writing the preference and reading the binding back in the same JS call. That is synchronous throughDesktopPreferences.set(it reassignsvaluesand bumpsrevisionbefore returning), but it has not been observed. If the fixture reportsthrough: 0, suspect binding timing before suspecting the gate. focusDurationMinutesis now editable on two pages: aSliderRowonWorkspacesPage.qmland segmented chips onFocusPage.qml. No contract catches it —settings-ownership-contractonly scans the five schema-bound row types and the chips are aSegmentedRowreadingDesktopPreferencesdirectly, andsearch-routing-contractonly seessetting:rows — but it is a real ownership violation bymodules/settings/README.md's own rule. Decide before the run: either the Workspaces row goes, or the key'sgroupmoves tofocusand the mirror gets named insettings-ownership-contract.- A stale comment in
config/PreferenceSchema.qmlabovefocusDurationMinutesstill says "The focus group routes to Notifications, which is where focusModes renders." It routes tofocusnow. Left for the schema's owner rather than edited across agent lines. - Run order for the sweep:
focus-modes-contractfirst (pure static, no compositor and no shell), thensettings-jump-contractandsearch-routing-contract(also static), thennotification-app-rules-contract(private D-Bus, isolated shell), andsettings-pages-contractlast — 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 throughSoundFeedback.playCommand. That was already true of the existingexercisefixture; 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 | PASS — readAs: "int", answers int |
PASS — prefs.getInt("focusOnClose", 0), def 0 |
scrollMethod |
input:scroll_method |
PASS — string enum, every offered word in the published list | PASS — readAs: "str", answers str |
PASS — prefs.get("scrollMethod", ""), def "" |
scrollButton |
input:scroll_button |
n/a — type: "int", not an enum |
PASS — readAs: "int", answers int |
PASS — prefs.get("scrollButton", 0), def 0 |
cursorHideWhileTyping |
cursor:hide_on_key_press |
n/a — bool | PASS — readAs: "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 | PASS — readAs: "int", answers int |
PASS — prefs.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 | PASS — readAs: "bool" |
PASS — def false |
touchpadTapAndDrag |
input:touchpad:tap-and-drag |
n/a — bool | PASS — readAs: "bool" |
PASS — def true |
Two of those are decisions, not just passes, and both are recorded in the schema itself:
focusOnClosewas 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.cursorWarpOnWorkspaceChangeis 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.lua — is_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 fromoption: "...", readAs: "..."pairs in the schema, so the seven new keys entered it the moment they landed. All seven extract cleanly and all sevenreadAsvalues match whathyprctl -j getoptionanswers with. Two were easy to get wrong and are worth naming:cursor:warp_on_change_workspaceanswersintdespite being a switch in the UI, andinput:scroll_methodanswersstrdespite the neighbouringscroll_buttonansweringint.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 aprefs.get()/prefs.getInt()inconfig/dot/hypr/input.luawith 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-contractandquickshell/gnome-handoff-contractare the three above.setup/readme-contractis a fourth: no contract file was added or removed,findstill 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 againstsettings-search-contract's fixed query list: of its 27 pinned queries onlypointermatches 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 inSettingsRoutes, 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-contractandsearch-routing-contractare now blind to dropdown rows. Both scan forsetting: "…"inside a fixed list of row types;OptionPickerRowis in neither list and carries nosetting:property.accelProfile,followMouse,focusOnCloseandscrollMethodare all invisible to them on the rebuiltMousePage.qml. Nothing fails today — none of those keys is a duplicate — but a duplicate introduced through a dropdown would not be caught.settings-pages-contractnow pins the seven new keys directly as a stopgap; the real fix is teaching both scans thePreferenceSchema.spec()/commitPreference()spelling. Owner: whoever holds those two contracts next.xkb-presets-contractnow requires a rawkeyboardLayoutfield, 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-contractpins the literal wordshowingin 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-dictatefalls back towl-copywhenwtypeis 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 whenDictation.typingAvailableis false. Nothing to fix; worth a look during the run if a machine withoutwtypeis 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-contractin full,settings-search-contract), andsettings-pages-contractlast, 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 toSUPER + SHIFT + F9against the real compositor with an isolatedXDG_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 -iwith a stub directory first onPATH, stubs for nmcli/gsettings/rfkill plus every D-Bus client, a PyGObject stand-in onPYTHONPATHwhoserequire_versionalways 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 ifnmcli,gsettingsorrfkillresolves 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-enterpriseis 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.
- The libnm/D-Bus branch of
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 installedquickshell-network.qmltypesrather 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 livefirewall-cmdread).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)" withnetworkandwifinow 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 -non every changed contract; every embeddedpython3heredoc compiled separately withpy_compile.
Still open before the run
Connectivity.qml:204names an enum member that does not exist.ConnectionFailReason.Authenticationis not a member; the real ones areNoSecrets,Unknown,WifiAuthTimeout,WifiClientDisconnected,WifiClientFailed,WifiNetworkLost. QML resolves it toundefined, the switch arm never matches, and a wrong password is reported with the fallthrough text.NoSecretsis 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 toundefinedin silence — can live anywhere that importsQuickshell.Networking, includingmodules/quicksettings/WifiList.qmland 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 holdsconnectivity-contractnext. network-tools-contractcannot 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-contractrequires the add flow to say "permanent" and "ask for your password", andsharing-contractrequires "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-contractpins the option vocabulary exactly —media∈ {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=1runs 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), thennetwork-tools-contractin 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), andsettings-pages-contractlast, 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 toconnectivityrather than to the Printers page. Ten entries replace them, all routing toconnectivity: 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.connectivityis a leaf inSettingsRoutes, 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-docsorpanama-settings-commandsto 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 onPATH, recording stubs for flatpak/rpm/pkexec and screaming stubs for dnf/yum/sudo/rpm-ostree/gio,HOMEand every XDG directory inside the scratch tree, and a fixture catalog throughPANAMA_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 toinstallwould 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
uninstallitself; 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" andfilesystems=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.
- Catalog agreement.
quickshell/applications-settings-contract— RUN END TO END, PASS (source-only). The bun-extractedroles/matchesRolehalf 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, stubxdg-mime/xdg-settings); the new half adds a fixturemime/globs2and two desktop entries that declareMimeType, then pinssearch-typesshape, that candidates are only installed applications, that a one-character query is not a search, thatset-typewrites 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,HOMEandXDG_CACHE_HOMEinside the scratch tree, the block tree fromPANAMA_DISKS_LSBLK, and recording stubs for gio/flatpak/pkexec/dnf/podman.- Two proofs run before anything is cleaned:
breakdown.pathmust be the fixture home, and the cache segment must be the 12 × 111111 bytes this contract wrote a moment earlier. Only then isclean cacheallowed 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 ==
usedBytesexactly,free==freeBytes, every segment non-negative, andexceedsUsedfalse 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, andpkexec dnf clean packages— neverclean 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_HELPERpointed 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_devicereading findmnt's SOURCE rather thanst_devis 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.
- Two proofs run before anything is cleaned:
quickshell/snapshots-contract(new half run, PASS). Retention is exercised against a recordingsnapperstub underenv -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
errorfield, not an exit code: this helper answers a refusal with fresh state and anerrorin it. Whether a refusal happened first is read from the absence of aset-configin 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_LIMITand the service'sretentionMax— 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.browseEntriesand 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.
- Refusals here are read from the JSON
Docs updated in the same wave
services/SettingsSearch.qml— nine entries added, all routing to leaves that exist inSettingsRoutes: Installed applications, Uninstall an application, Application permissions, Install applications, Autostart, File associations →applications; Clean up storage, Application caches →storage; Snapshot retention →snapshots. Checked by evaluating the array: 154 entries, no duplicate labels, everypagea real leaf, and none ofsettings-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 pinnedvolumequery; 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-contractandsnapshots-contracthave 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-contracthas 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-contractandsettings-buttons-contractwere 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 byapplications-settings-contract, but nothing here has loaded the QML.app-library-contractpins the fixture seamPANAMA_EXTRAS_DIRand 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), andsettings-pages-contractlast, 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 onPATH(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_PATHpointed at a recordingpanama-sudothat runs nothing, and enrollment replayed fromPANAMA_FINGERPRINT_FIXTUREwith the calls it stood in for landing inPANAMA_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-contractlost itsUsersPage.qml:system-usersexception 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,statusmust still answerunlockFeatureEnabled: 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.qmlascardVisible: 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,totalconstant, 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 callRelease,EnrollStartafter 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
enrollandremove, each of which must leave the call log empty. removedeletes the finger it was asked about, andremove-allusesDeleteEnrolledFingers2with 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 baresudo/pkexecin 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.
- The inverted needle.
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-passwordcarries no password material, by AST: it must callSetPasswordMode, must resolve its mode argument to 1 (through the named constant, so the meaning survives), must not callSetPassword, 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,removeFiles→keepFiles), and the page passes a variable rather than a constant. Both vocabularies are exercised —keep/removeand the legacykeep-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. removeIconsends an empty path, andset_iconrecognizes 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 noTextFieldRowat all. The page's regex is compared character for character against the helper'sUSERNAME, 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). TheUsersPage.qml:system-usersexception is deleted; theOnlineAccountsPage.qml:online-accountsone 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 landedpanama-accountsdoes 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 onPATH(gdbus/busctl/dbus-send/ gnome-control-center all resolve there, asserted before running), agistand-in onPYTHONPATHwhoserequire_versionalways 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 fromPANAMA_ACCOUNTS_FIXTURE; what each verb would have done lands inPANAMA_ACCOUNTS_LOG. What it pins:- Availability is not an error, at the definition and at every assignment:
no line that decides
availablemay 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
passwordBytesproves 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_syncmust be followed bycall_ensure_credentials_sync, andverifymust be able tocall_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) andimap_smtp. A provider type GOA does not know is not an error — AddAccount is simply never offered it. - Both names for the listing (
listandsnapshot) 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
confirmingproperty declared anywhere in the file.
- Availability is not an error, at the definition and at every assignment:
no line that decides
Docs updated in the same wave
services/SettingsSearch.qml— nine entries added, all routing to leaves that exist inSettingsRoutes: Fingerprint, Enroll a fingerprint, Delete a user, Account type, Reset a password →users; Nextcloud, Google account, Add a mail account, Remove an account →accounts. Checked by evaluating the array: 163 entries, no duplicate labels, everypagea real leaf, and none ofsettings-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 matcheslockthrough "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-contractfails 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_urisaccepts anything with a host oncehttps://is prepended, andimap_accountaccepts any non-empty string as a server:add-nextcloudacceptsnot a url,https://cloud.example.org; reboot, andhttps://cloud example org;add-imapacceptsimap example com,imap.example.com; reboot, and-imap.example.comas either server.
A hostname check in
nextcloud_urisand one applied to both hosts inimap_accountcloses all six.'',ftp://…andhttps://(no host) are already refused, and every other assertion in the file passes. -
settings-search-contracthas 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-contractandsettings-buttons-contractwere not run against the two rebuilt pages. Six new components landed (LiveFieldRow, SecretFieldRow, PasswordStrengthRow, StockAvatarPicker, FingerprintEnrollPanel, OnlineAccountRow) and are registered inqmldir, 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
SIGTERMreleasing the device is exercised by neither half; what is pinned is that the release happens in afinallyand 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), andsettings-pages-contractlast, 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 underenv -iagainst a recordingbusctlfirst onPATH(asserted to resolve inside the stub directory before anything that writes runs), withDBUS_SESSION_BUS_ADDRESSpointed 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 —
SetPermissionis named exactly once in the file, inside one function, which consults a named list containingcamera; that list must not containscreencastorremote-desktop, and the function must not name them either. And at runtime —set screencast …is refused and the call log gains noSetPermission, 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, andforgethas to issue twoDeletePermissioncalls. 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
Listanswers 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 microphoneon stderr with a non-zero exit, and the snapshot must report an empty microphone table witherror: "". speakersis 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.devicessurvives 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.
- 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 —
quickshell/privacy-traces-contract— RUN END TO END, PASS. New, and hermetic throughout:env -iwithHOME,XDG_DATA_HOMEandXDG_CACHE_HOMEinside 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/thumbnailsat a directory outside the home, which has to be refused in words. - The seam is not a way around the guard:
PANAMA_PRIVACY_THUMBNAILSpointed 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 doesTraces.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.
- Clearing recents empties the file, never unlinks it, and what is left
parses as XML with
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 underenv -iwithHOMEin a scratch tree and a recordingssh-keygenfirst onPATH, which logs its own/proc/$$/cmdlineand/proc/$$/environand 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 intype_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, noopen/mkstemp/write_text. - The environment ssh-keygen runs in, read back out of
/proc:SSH_ASKPASS_REQUIRE=never(this desktop setsprefer, which draws a graphical dialog and hangs the pty until the timeout — A found this the hard way) andLC_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-permissionsrestores 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-addwas 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 buttonconfirming ? "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 readconfirming, 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).privacyjoinsOWNED, 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.qmlcontains noopenGnomePanelat all — because the generic loop can only fail on a door that exists.quickshell/settings-ownership-contract— RUN, PASS. ThelockMinutes/lockOnSleepmirrors ontoprivacyare gone from the expected table and from the README loop;modules/settings/README.mdlost 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 newPrivacyLiveTileandSectionLabel),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 countshyprlockprocesses, 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/lockOnSleepmust 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 landedPrivacyPage.qmlandPowerPage.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 ofSettingsSearch.qmland confirmed to route toprivacyandssh-keys, both of whichsettings-nav-contractconfirms are leaves.quickshell/settings-pages-contractandsettings-write-sweep-contract— NOT RUN: both load the QML. Neither page has been rendered by anything in this phase.Traces.qmlis 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 againstHOME, so a hermetic run pointsHOMEat a scratch tree rather than pointing the seams out of it;panama-ssh-keysreadsPath.home(), soHOMEalone 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), andsettings-pages-contractlast, 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 stubbedpanama-hwanswers the predicates, a stubbedhyprctlanswers the monitor query from a fixture and records the rule instead of applying it, andXDG_CONFIG_HOMEpoints 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.monitorrule carrying every one of them, withpositionbuilt from the stored coordinates rather than"auto". - The old four-key emission fails it, verified by running the check
against the pre-fix
cmd_openbefore 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 (anxwith noy) is refused the same waymonitors.luarefuses it. nullnever 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, neverkeyword, so even a wrong rule dies at the next reload.
- 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
quickshell/battery-contract— RUN END TO END, PASS. Existing pins unchanged; still driven against fixture sysfs trees. New: health is read fromenergy_full/energy_full_designAND from thecharge_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 getsnull, not a confident100%and0 cycleson 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 inhibitorsagainst a fakebusctl, 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 poweroffon 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 inshell.qmlruns 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. powerButtonActioncarries nohyprblock: 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
SettingsCardblock, 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
shutdownhold 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;delayinhibitors sort belowblockones, 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.
- The power button cannot power the machine off. Said three ways, because
this is the assertion the redesign turns on: no
hypr/idle-config-contract,hypr/idle-defaults-contract,quickshell/powermenu-contract— RUN, PASS, unchanged. Verified rather than assumed: theinhibitorsverb is additive (it adds noread_settingcall, which is whatidle-defaults-contractwalks) and the powermenu's pre-select argument leftlogoutScriptalone.quickshell/search-routing-contract— RUN, PASS (145 routed settings, up from 144:powerButtonActionis 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-docsandpanama-settings-commandswere both run without--checkand their output committed:docs/settings.mdgoes 172 → 173 settings across 36 groups, with the newpowergroup rendering as "Found on Power & Lock" — the group comment shape the spec asked C to verify, which comes from the"power": "power"route added toSettingsSearch.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 topower, whichsettings-nav-contractconfirms 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 expectslockMinutes,lockMinutesBatteryandlockOnSleepbound on Power, which the rebuilt page keeps.- One thing the two agents disagree about, left as it landed. A's
IdleLockexposesinhibitorsKnownbesideinhibitors, 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.
CanHibernateanswers 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 overstepZoom's own text: the slice isstepZoomplus every function inAccessibility.qmlit 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: nohyprctl, nokeyword, aSystemSettings.commitPreferencecall, anOsdStatepost, and clamp bounds that either readPreferenceSchema.specor match the schema's own 1.0–5.0. TheaccessibilityIPC handler inshell.qmlis 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 againstkeybinds.luarather than against the mock — a contract namingSUPER+=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, noloops: -1, no Timer withrepeat: trueanywhere behind it, and it readsvisualAlerts. - The flash follows bell eligibility EXCEPT the event-sounds gate. The
signal name is read out of
VisualBell.qml'sConnections { target: Notifs }rather than guessed, which also pins that the two are really wired. Two structural checks then run overNotifs.qml: noSoundFeedback.eventSoundsearly 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-enabledis grepped for repo-wide across the shell's QML and JS, comment-stripped first. Plus:orcaRunningexists, somethingpgreps, 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.roleandactiveFocusOnTabin each ofSliderRow,ToggleRow,SwitchRow,ActionRow,ChoiceRow,SegmentRow,SettingRow,OptionPickerRow,PickerRow, resolved through the QML inheritance chain (a name inherited fromSettingRowis a name the row really has) — with one exception that is NOT inheritable: a file drawing its ownMouseArea/SettingsToggle/SettingsButton/ValueSlidermust declare its ownactiveFocusOnTab, becauseSettingRowis a Tab stop only whenactivatable, which aToggleRowis not. Then: Space/Enter activation on the four rows that activate, Left/Right on the two that hold a range, a focus indicator gated onactiveFocus, 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,
dimStrengthgated ondimInactive, the mono-audio row present and marked not-yet with somewhere to go meanwhile, the Keyboard jump going throughopenSettings()rather than assigningsettingsPageby hand, and noProcesson 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.warnor 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()substituteszeroLabelat exactly 0, so a row setting one for a setting whose schema minimum is above 0 is copy that can never appear — which is whatzeroLabel: "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.motionEnabledreadsSettings.animationsEnabled, and all four duration tokens collapse to 0. - The cross-goal collision, which is silent.
VisualBellmust not time its flash with aTheme.dur*token: those now collapse to 0 under Reduce motion, so a flash timed withTheme.durFastwould 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 ofplayBellinto the newbellWouldRingpredicate, and the contract read onlyplayBell'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 thatplayBellactually 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)iszoom-in-symbolicandiconFor('zoom', 0)iszoom-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/fordurFast/durNormal/durSlow/durDockRevealandmotionEnabled: zero hits before the change, so gating them broke nothing.accessibility-contractnow 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 exactlyanimationsEnabled,cursorInactiveTimeout,cursorSizeandinactiveOpacityto 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.qmlis 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 brokenSliderRowtakes every settings page down, and only these would see it.qmldir-registration-contract(static) was run and passes, soSettingsNoteat 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.
VisualBellis 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 orcamatches the installed Orca is A's verified claim in a comment (it callsset_process_name("orca"), which renamescomm), not something this contract re-checks. - The screen reader has not read the page.
Accessible.name,roleandactiveFocusOnTabare 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-contractfails, 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 oftests/hypr/), then the sandboxed-harness ones, then the live settings window last.