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

56 KiB
Raw Blame History

Settings redesign — deferred test runs

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

The run

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

Known items to verify or investigate at the end

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

Phase 3 (Appearance) — append below

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

New contracts (3)

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

Updated contracts (6)

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

Verified against the new tree, no edit needed

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

Docs updated in the same wave

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

Still open before the run

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

Phase 4 (Shell category) — append below

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

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

New contracts (1)

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

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

Updated contracts (5)

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

Verified against the new tree, no edit needed

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

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

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

Docs updated in the same wave

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

Still open before the run

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

Phase 5 (Displays) — append below

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

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

Contracts changed (4)

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

Cross-agent shapes these contracts now pin

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

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

Still open before the run

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

Phase 6 (Sound) — append below

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

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

New contracts (3)

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

Updated contracts (4)

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

Cross-agent shapes these contracts pin

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

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

Docs updated in the same wave

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

Still open before the run

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

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

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

Phase 7 (Notifications & Focus) — append below

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

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

New contracts (0)

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

Updated contracts (4)

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

Contracts deliberately left alone

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

Docs updated in the same wave

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

Still open before the run

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

Phase 8 (Input) — append below

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

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

New contracts (0)

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

The seven new schema keys, per key

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

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

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

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

Updated contracts (6)

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

Verified against the new tree, no edit needed

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

Docs updated in the same wave

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

Still open before the run

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