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

1675 lines
124 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | `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 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 `SoundDeviceList`s, 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 `cat`s 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.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 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 | **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:
- `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.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 from
`option: "...", readAs: "..."` pairs in the schema, so the seven new keys
entered it the moment they landed. All seven extract cleanly and all seven
`readAs` values match what `hyprctl -j getoption` answers with. Two were easy
to get wrong and are worth naming: `cursor:warp_on_change_workspace` answers
`int` despite being a switch in the UI, and `input:scroll_method` answers
`str` despite the neighbouring `scroll_button` answering `int`.
- `tests/hypr/hypr-prefs-contract` — pure static, and **run**: ok, 77
compositor-owned keys read at config time, up from 70. All seven new keys
have a `prefs.get()`/`prefs.getInt()` in `config/dot/hypr/input.lua` with a
fallback equal to the schema default.
- `quickshell/gnome-handoff-contract` — needle-free by construction (it derives
both sides). **Run**: ok, 14 handoffs checked against 39 pages. The Keyboard
page still has no GNOME handoff and still explains why.
- `quickshell/schema-hypr-shape-contract`, `tests/hypr/hypr-prefs-contract` and
`quickshell/gnome-handoff-contract` are the three above. `setup/readme-contract`
is a fourth: no contract file was added or removed, `find` still counts 169,
and the README still claims 169.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — three hand-written entries: **Rebind a
shortcut** (→ `shortcuts`), **Pointer test area** and **Connected input
devices** (→ `mouse`). "Key repeat" and "Scroll method" arrive automatically
from the schema, as the spec expected. Checked against
`settings-search-contract`'s fixed query list: of its 27 pinned queries only
`pointer` matches any new entry, and "Pointer test area" sorts *after*
"Pointer focus", "Pointer size" and "Pointer speed" in the same prefix rank,
so no pinned top result moves. Both new pages are leaves in
`SettingsRoutes`, so the "routes to a page anyone can land on" sweep holds.
- No settings docs or launcher commands were regenerated here — that is the
orchestrator's step after the schema settled.
### Still open before the run
- **`settings-ownership-contract` and `search-routing-contract` are now blind
to dropdown rows.** Both scan for `setting: "…"` inside a fixed list of row
types; `OptionPickerRow` is in neither list and carries no `setting:`
property. `accelProfile`, `followMouse`, `focusOnClose` and `scrollMethod`
are all invisible to them on the rebuilt `MousePage.qml`. Nothing fails
today — none of those keys is a duplicate — but a duplicate introduced
through a dropdown would not be caught. `settings-pages-contract` now pins
the seven new keys directly as a stopgap; the real fix is teaching both
scans the `PreferenceSchema.spec()` / `commitPreference()` spelling. Owner:
whoever holds those two contracts next.
- **`xkb-presets-contract` now requires a raw `keyboardLayout` field**, on the
reading that the layout dropdown's "Custom…" has to reveal somewhere the
code can actually be typed. It passes against the landed page. If the layout
editor is ever folded into the dropdown itself, that needle is the one to
revisit — the intent is "the raw code stays typeable", not "it is a
TextEntryRow".
- **`keybinds-contract` pins the literal word `showing`** in the filtered-count
line, because the spec names that wording ("showing N of M"). It is the one
prose needle in the new static half; everything else keys on structure.
- **Dictation's Try-it field and the clipboard fallback** — handled, but worth
knowing. `panama-dictate` falls back to `wl-copy` when `wtype` is missing, by
design, and on the Try-it row that means the words land on the clipboard
rather than in the field the page just focused. The page says so: a "Typing —
Missing" row appears when `Dictation.typingAvailable` is false. Nothing to
fix; worth a look during the run if a machine without `wtype` is around, since
that branch has never been seen.
- Run order for this phase: the two source-only contracts first
(`xkb-presets-contract`, `gnome-handoff-contract`), then the static halves
(`hypr-prefs-contract`, `PANAMA_KEYBINDS_STATIC_ONLY=1 keybind-rebind-contract`,
`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract`), then the two
compositor-query contracts (`enum-hypr-map-contract`,
`schema-hypr-shape-contract` — read-only, but they want the real compositor),
then the harness contracts (`keybinds-contract`, `keybind-rebind-contract`
in full, `settings-search-contract`), and `settings-pages-contract` last, as
before: it starts an isolated Quickshell beside the live one and its own
cleanup is what protects the running session.
- `keybind-rebind-contract`'s live half rebinds Terminal to `SUPER + SHIFT +
F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That
was true before this phase and is unchanged, but it is the one contract in
this wave that writes to the running keymap, so it wants a quiet moment.
## Phase 9 (Network & Sharing) — append below
Spec: `2026-08-24-network-sharing-redesign.md`. Connections became a full
network manager (per-connection details, forget, autoconnect, MAC
randomization, VPN import, hotspot, enterprise Wi-Fi, airplane mode, system
proxy), Firewall grew its add side and a zone browser, Printers gained per-
printer defaults and job hold/release, and Sharing got copy and structure work.
The last two GNOME punt rows in the app died with the "Owned by Fedora" card.
**Nothing here was run against a live harness.** Three agents edited the tree
concurrently. What *was* verified is listed per contract below.
### New contracts (1)
`quickshell/network-tools-contract`. The README count line moves **169 → 170**;
`setup/readme-contract` was run and passes ("170 contracts, as documented"),
counted the same way `panama test` collects the suite.
### Verified
- **`quickshell/network-tools-contract` — RUN END TO END, PASS.** It is safe to
run because it never reaches the machine: `env -i` with a stub directory first
on `PATH`, stubs for nmcli/gsettings/rfkill plus every D-Bus client, a
PyGObject stand-in on `PYTHONPATH` whose `require_version` always raises, and
both D-Bus bus addresses pointed at sockets that do not exist. It asserts the
first two of those before running the helper at all, and refuses to continue
if `nmcli`, `gsettings` or `rfkill` resolves anywhere but the stub directory.
Checked with a debug copy that it is doing real work rather than passing
vacuously: the stub's terse listing drives the shipped parse all the way to
`{"ip4":"192.168.7.42/24","gateway":"192.168.7.1","dns":["192.168.7.1",
"1.1.1.1"],"mac":"AA:BB:CC:DD:EE:FF","macRandomized":true,…}` while the three
secret properties in the same listing are absent from it.
- The libnm/D-Bus branch of `join-enterprise` is deliberately **not**
exercised — running it would add a real connection profile. It is covered
statically instead, by an AST walk that fails if any command list in the
helper carries a password-shaped identifier. The nmcli fallback is what the
dynamic half drives, and there the password is asserted present on the
editor's **stdin** and absent from **argv**, stdout, stderr and disk.
- **`quickshell/connectivity-contract` — static half run, and it found a bug.**
See "Still open" below. The new enum check reads the member list out of the
installed `quickshell-network.qmltypes` rather than a hand-kept list, so it
cannot drift the way the two bugs it catches did.
- **`quickshell/firewall-contract` — static half run, PASS** (everything up to
the first live `firewall-cmd` read).
- **`quickshell/printers-contract` — static half run, PASS.**
- **`quickshell/sharing-contract` — static half run, PASS.**
- **`quickshell/gnome-handoff-contract` — RUN END TO END, PASS** (source-only):
"ok (12 handoffs checked against 39 pages)" with `network` and `wifi` now in
OWNED.
- **`quickshell/health-ui-contract` — static half run, PASS.**
- **`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract` — PASS.**
- **`setup/readme-contract` — RUN END TO END, PASS.**
- `bash -n` on every changed contract; every embedded `python3` heredoc compiled
separately with `py_compile`.
### Still open before the run
- **`Connectivity.qml:204` names an enum member that does not exist.**
`ConnectionFailReason.Authentication` is not a member; the real ones are
`NoSecrets`, `Unknown`, `WifiAuthTimeout`, `WifiClientDisconnected`,
`WifiClientFailed`, `WifiNetworkLost`. QML resolves it to `undefined`, the
switch arm never matches, and a wrong password is reported with the
fallthrough text. `NoSecrets` is almost certainly the intended member.
`connectivity-contract`'s new enum check fails on exactly this line and passes
on a copy with it corrected, so the check is confirmed working and the fix is
a one-word edit in a service file this phase's contract owner does not own.
**This is the one item blocking a clean run.**
- **The enum check is scoped to `Connectivity.qml`.** The same class of bug —
a plausible enum member that does not exist, resolving to `undefined` in
silence — can live anywhere that imports `Quickshell.Networking`, including
`modules/quicksettings/WifiList.qml` and the bar's status cluster. Widening
the sweep to every QML file that imports the module is a small change and the
obvious next one. Owner: whoever holds `connectivity-contract` next.
- **`network-tools-contract` cannot see the libnm branch.** That is a deliberate
trade, but it means the branch that actually runs on this machine (NM 1.56,
bindings present) is only ever checked by reading. A machine without PyGObject
would exercise the fallback for real; nothing here has one.
- **Two contracts still pin prose.** `firewall-contract` requires the add flow
to say "permanent" and "ask for your password", and `sharing-contract`
requires "never passes through Panama", "install it and this becomes a switch"
and "does not install software". The spec names all five phrases, so they are
pinned deliberately, but they are the needles a copy edit will trip.
- **`printers-contract` pins the option vocabulary 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=1` runs the new source-only half
alone.
- Run order for this phase: the source-only contracts first
(`gnome-handoff-contract`, `PANAMA_NETWORK_STATIC_ONLY=1
network-tools-contract`, `PANAMA_CONNECTIVITY_STATIC_ONLY=1
connectivity-contract`, `PANAMA_SETTINGS_STATIC_ONLY=1
settings-pages-contract`, `setup/readme-contract`), then
`network-tools-contract` in full — it is hermetic, so it can run at any time —
then the read-only system contracts (`firewall-contract`,
`printers-contract`, `sharing-contract`), then the harness contracts
(`connectivity-contract`, `health-ui-contract`, `settings-search-contract`),
and `settings-pages-contract` last, as before.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — the three "Managed by GNOME Settings"
connectivity entries are gone, and with them the duplicate **Printers** entry
that routed to `connectivity` rather than to the Printers page. Ten entries
replace them, all routing to `connectivity`: **Wi-Fi**, **Bluetooth**, **VPN**,
**Import a VPN**, **Hotspot**, **Airplane mode**, **Network proxy**, **IP
address**, **Forget a Wi-Fi network**, **Enterprise Wi-Fi**. Wi-Fi and
Bluetooth were rewritten rather than deleted: the spec lists eight additions,
none of which contains the word "Bluetooth", so deleting the lying entry
outright would have made a switch the page has unreachable by search.
- Checked against `settings-search-contract`'s pinned query list: none of its 21
ranked queries, nor the nine leaf-routing queries, matches any new or changed
entry, so no pinned top result moves. `connectivity` is a leaf in
`SettingsRoutes`, so the routing sweep holds.
- No settings docs or launcher commands were regenerated: this phase added no
schema keys, so there is nothing for `panama-settings-docs` or
`panama-settings-commands` to pick up. Verified by reading the diff — every
new setting here is system state (NetworkManager, firewalld, CUPS, gsettings),
not a Panama preference.
## Phase 10 (Applications, Storage & Snapshots) — append below
Spec: `2026-08-24-applications-storage-snapshots-redesign.md`. Applications
became an app manager (installed list with search, Flatpak uninstall, permission
summaries, per-app autostart, jump chips, the `panama apps` catalog rendered
natively, role dropdowns plus a single-type override). Storage grew a
proportional breakdown bar and an itemized "Clean up, honestly" card, and lost
the duplicate container-images row that opened a terminal. Snapshots made
retention editable and moved the file browser into its own card, which fixes
"browse from a collapsed volume card does nothing".
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase rather than against the spec's
pinned shapes.
### New contracts (1)
`quickshell/app-library-contract`. The README count line moves **170 → 171**;
`setup/readme-contract` was run and passes ("171 contracts, as documented").
### Run and passing
These four are hermetic or source-only, so they were run rather than deferred:
- **`quickshell/app-library-contract` — RUN END TO END, PASS.** Safe to run
because it never reaches the machine: `env -i`, a stub directory first on
`PATH`, recording stubs for flatpak/rpm/pkexec and screaming stubs for
dnf/yum/sudo/rpm-ostree/gio, `HOME` and every XDG directory inside the scratch
tree, and a fixture catalog through `PANAMA_EXTRAS_DIR`. It asserts, before
running the helper at all, that the helper names no binary by absolute path
and that all six commands resolve inside the stub directory. What it pins:
- **Catalog agreement.** `setup/lib/extras-catalog`'s bash functions and the
helper's Python parser are both run over the same fixture files and their
answers compared entry by entry — id, label, kind — rather than either being
read. The fixture exercises the format's corners: a labelled entry, an
unlabelled Flathub id (label derived from the last dotted component), a bare
dnf name, a `|`-labelled dnf name, comments, blank lines, and two indented
continuation lines that must fold into the entry above.
- **Ids are the catalog line verbatim.** `flatpak:org.example.Bravo`, not the
stripped ref: the bare ref is in the refusal list, because handing that to
`install` would be refused for every Flathub entry in the real catalog and
the failure would read as "installing is broken".
- **Nothing off-catalog installs**, and the empty command log is what proves
the refusal came first. Off-catalog ids, an unknown category, a path
escaping the catalog directory, a continuation line, and an entry that
exists in the *other* category are all refused with nothing run.
- **Extensions ride with their entry and nothing else does** (installing Echo
reaches for Echo and its two plugins, and no other `org.example.*`).
- **Removal is flatpak-only**, pinned three ways: an AST walk that fails if
any command list in the helper pairs dnf/yum/rpm-ostree with a removal verb
or invokes rpm for anything but a query; a second AST walk over `uninstall`
itself; and a runtime check that no argument — including a dnf package name
— produces a package-manager call.
- **Permission buckets from data**: `filesystems=host` → "Full file system
access" and `filesystems=home` → "Home folder" are checked against separate
fixtures so the two cannot be conflated; `devices=all` → Camera,
`sockets=pulseaudio` → Microphone, `shared=network` → Network; an
unrecognized key must still appear in the output; and a sandboxed
application must summarize strictly shorter than a permissive one, so the
summary cannot be a constant.
- **`quickshell/applications-settings-contract` — RUN END TO END, PASS**
(source-only). The bun-extracted `roles` / `matchesRole` half survives B's
rebuild verbatim and gained four fixtures, including the two shapes that once
made Archives match nothing: categories arriving as a QML list, and as a
comma-separated string.
- **`quickshell/default-apps-contract` — RUN END TO END, PASS.** Already
fixture-driven (its own XDG tree, stub `xdg-mime`/`xdg-settings`); the new
half adds a fixture `mime/globs2` and two desktop entries that declare
`MimeType`, then pins `search-types` shape, that candidates are only installed
applications, that a one-character query is not a search, that `set-type`
writes exactly one type, and that an unknown type, an unknown application, and
a type this system does not have are each refused with nothing reaching
xdg-mime.
- **`setup/readme-contract`, `quickshell/search-routing-contract`,
`quickshell/settings-ownership-contract` — RUN, PASS** after the nine new
search entries.
### Extended contracts, new halves run in isolation (3)
`disks-contract` and `snapshots-contract` each keep a first half that reads the
live machine (`panama-disks snapshot`, `panama-snapshots snapshot`), so neither
was run end to end. The **new** halves were extracted into scratch scripts and
run on their own, and both pass:
- **`quickshell/disks-contract`** (new half run, PASS). It is hermetic: `env -i`,
`HOME` and `XDG_CACHE_HOME` inside the scratch tree, the block tree from
`PANAMA_DISKS_LSBLK`, and recording stubs for gio/flatpak/pkexec/dnf/podman.
- **Two proofs run before anything is cleaned**: `breakdown.path` must be the
fixture home, and the cache segment must be the 12 × 111111 bytes this
contract wrote a moment earlier. Only then is `clean cache` allowed to run —
which is what makes it safe to assert that the symlink planted inside the
fixture cache was unlinked rather than followed, and that the file it points
at outside the cache survived.
- **The arithmetic**: home + applications + caches + system == `usedBytes`
exactly, `free` == `freeBytes`, every segment non-negative, and
`exceedsUsed` false on the fixture. The two ways the measurement can be
wrong (`complete === false`, `exceedsUsed === true`) are pinned as things
the page says out loud.
- **The anti-racket stance**, pinned as an absence in the cleanup card's own
string literals: no "running out", "recommended", "act now", "junk", "safe
to remove", "you should", no exclamation marks, and no single button that
clears everything. The extractor takes the *tightest* card whose own title
is the cleanup one, and scans only double-quoted literals — QML is full of
`!` and none of it is shouting.
- **Each cleanable does its own one thing**: `gio trash --empty` (never rm),
`flatpak uninstall --unused --noninteractive`, and
`pkexec dnf clean packages` — never `clean all`, never a removal. An AST
walk pins that this is the *only* package-manager command in the whole
helper.
- **The unused-runtime size is borrowed, not recomputed**: with
`PANAMA_APPLICATIONS_HELPER` pointed at a recording stub, the row must
report what that stub said (780000) and the stub must have been asked. Two
ideas of "unused" would show one number and free another.
- `backing_device` reading findmnt's SOURCE rather than `st_dev` is pinned
with the reason: btrfs gives every subvolume its own device number, and the
first version of the breakdown lost the system-wide flatpak installation to
exactly that.
- **`quickshell/snapshots-contract`** (new half run, PASS). Retention is
exercised against a recording `snapper` stub under `env -i`, after asserting
snapper resolves inside the stub directory — the only way to run a write verb
without changing how this machine keeps its snapshots.
- Refusals here are read from the JSON `error` field, not an exit code: this
helper answers a refusal with fresh state and an `error` in it. Whether a
refusal happened *first* is read from the absence of a `set-config` in the
log, because the helper reads the configuration list on its way back out
either way.
- Ceiling pinned at **50 in both places** — the helper's `RETENTION_LIMIT` and
the service's `retentionMax` — because a dropdown offering a value the
helper refuses fails after the user has already chosen.
- The browser-card fix is pinned structurally: a brace-aware scan (strings and
comments skipped) walks the QML ancestry of `Snapshots.browseEntries` and
fails if the enclosing card is a delegate of the per-volume Repeater. This
was checked against the OLD page first, where it correctly fails, so it is
not a check that passes on anything.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — nine entries added, all routing to leaves that
exist in `SettingsRoutes`: **Installed applications**, **Uninstall an
application**, **Application permissions**, **Install applications**,
**Autostart**, **File associations** → `applications`; **Clean up storage**,
**Application caches** → `storage`; **Snapshot retention** → `snapshots`.
Checked by evaluating the array: 154 entries, no duplicate labels, every
`page` a real leaf, and none of `settings-search-contract`'s 21 ranked queries
or 9 leaf-routing queries matches a new entry, so no pinned top result moves.
(The first draft of the Snapshot retention detail said "each volume keeps",
which put it in the result set for the pinned `volume` query; reworded.)
- No settings docs regenerated: this phase adds no schema keys. Every new
setting is system state (flatpak, dnf, snapper, xdg-mime), not a Panama
preference — verified by reading the diff.
### Still open before the run
- **`disks-contract` and `snapshots-contract` have not been run end to end.**
Their first halves read the live machine, so they want the same quiet moment
the other system contracts do. Nothing in the new halves depends on that
order.
- **`settings-search-contract` has not been run**: it starts a Quickshell
harness. The nine new entries were checked statically as described above, but
the schema-label sweep and the ranked queries need the live harness.
- **`settings-pages-contract`, `settings-docs-contract`, `settings-jump-contract`
and `settings-buttons-contract` were not run against the three rebuilt pages.**
ApplicationsPage grew four new components (InstalledAppRow, FileTypePicker,
SettingsChip, StorageBreakdownBar); the qmldir registration of the first three
is pinned by `applications-settings-contract`, but nothing here has loaded the
QML.
- **`app-library-contract` pins the fixture seam `PANAMA_EXTRAS_DIR` and the
permission bucket wording** ("Camera", "Microphone", "Full file system
access", "Home folder", "Network", "Devices"). Both match the landed helper;
changing either is meant to be a deliberate act that updates this contract,
and will read as a surprise the first time somebody tries.
- Run order for this phase: the hermetic and source-only ones first
(`app-library-contract`, `applications-settings-contract`,
`default-apps-contract`, `search-routing-contract`,
`settings-ownership-contract`, `setup/readme-contract`), then the read-only
system ones (`disks-contract`, `snapshots-contract`, `containers-contract`),
then the harness ones (`settings-search-contract`, `health-ui-contract`), and
`settings-pages-contract` last, as before.
## Phase 11 (Users & Accounts) — append below
Spec: `2026-08-24-users-accounts-redesign.md`. Users grew a profile hero with a
stock-avatar gallery and a Remove, a password flow with a strength meter, a
live-validating add-user form, a keep-files deletion choice, and per-user
management (type / reset password / unlock). Fingerprint enrollment stopped
handing people to GNOME's Users panel and became native — fprintd's
`EnrollStart` driven from `panama-fingerprint`, streaming one JSON line per
touch — and the card learned to appear in the state that needs acting on rather
than only when a reader is attached. Online Accounts gained native Nextcloud and
IMAP forms, confirmed removal, and an availability that no longer collapses on a
failed write.
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase, not against the spec's pinned
shapes: three of the needles moved during the phase (the card-visibility
expression went into the service as `cardVisible`, the add-user field became
`LiveFieldRow`, and the fingerprint helper became Python) and each is pinned
where it actually landed.
### New contracts (1)
`quickshell/online-accounts-contract`. The README count line moves
**171 → 172**; `setup/readme-contract` was run and passes ("172 contracts, as
documented").
### Run and passing
- **`quickshell/fingerprint-contract` — RUN END TO END, PASS.** The whole file
was reworked, because two of its needles inverted. Hermetic: `env -i`, a stub
directory first on `PATH` (asserted, before the helper runs, that
fprintd-list, authselect, sudo and pkexec all resolve inside it), both bus
addresses pointed at sockets that do not exist, `PANAMA_PATH` pointed at a
recording `panama-sudo` that runs **nothing**, and enrollment replayed from
`PANAMA_FINGERPRINT_FIXTURE` with the calls it stood in for landing in
`PANAMA_FINGERPRINT_LOG`. What it pins:
- **The inverted needle.** `openGnomePanel("system", "users")` is now a
failure rather than a requirement, checked across every settings page.
`gnome-handoff-contract` lost its `UsersPage.qml:system-users` exception at
the same time; this is the other half of that, said from the page's side.
- **The stuck state, from the data.** With the stub fprintd reporting no
devices and the stub authselect reporting `with-fingerprint`, `status` must
still answer `unlockFeatureEnabled: true` — the combination that used to be
unrepresentable, because the feature was only read when fprintd answered.
This machine is in that state, which is how it was found.
- **Card visibility followed one level of indirection.** The expression moved
into `services/Fingerprint.qml` as `cardVisible: readerPresent ||
unlockFeatureEnabled`; the contract accepts either the page spelling it out
or the page binding to a service property that does, and resolves the second
rather than forbidding it.
- **The enrollment stream as a stream.** A five-stage fixture with a retry in
the middle produces nine lines; every one must be a JSON object on its own,
the counter monotonic and inside `0..total`, `total` constant, and the
retried touch must **not** have advanced the counter — a panel that counts a
failed touch promises a finish that never arrives. The terminal line says
which way it ended.
- **Claim and Release bracket everything**, read from the call log: first call
`Claim`, last call `Release`, `EnrollStart` after the claim — including on a
failed enrollment, because a reader left claimed refuses the next attempt
with "busy with something else" forever.
- **A terminal failure is reported in words**, not in fprintd's vocabulary
(`enroll-duplicate` → "That finger is already enrolled").
- **Nonsense is refused before the reader is touched**: four bad finger names
against both `enroll` and `remove`, each of which must leave the call log
empty.
- **`remove` deletes the finger it was asked about**, and `remove-all` uses
`DeleteEnrolledFingers2` with no arguments — its predecessor took a user
name, which is how a typo deletes somebody else's prints.
- **With no fixture and no bus it still answers in its own shape**: the page
reads this stream line by line, and a traceback leaves an enrollment panel
open forever.
- **The stated reason survived the rewrite to Python**: the escalation must go
through `panama-sudo --reason …`, and a bare `sudo`/`pkexec` in the argv log
is a failure.
- **Finger vocabulary**: a single id elsewhere is a default choice and is
allowed; two in one file is a copy of the list, and the copy is what falls
behind.
- **`quickshell/user-accounts-contract` — RUN END TO END, PASS.** Its snapshot
half still reads the live account service, which is read-only; every verb that
would change something is pinned from the source and never invoked
(`set-icon gib ""` really does clear the avatar). New halves:
- **`reset-password` carries no password material**, by AST: it must call
`SetPasswordMode`, must resolve its mode argument to **1** (through the
named constant, so the meaning survives), must not call `SetPassword`, and
its body — docstring excluded, because the docstring is allowed to say the
word — must not mention stdin, openssl, crypt or passwd.
- **keep-files end to end, as three separate links**, because any one of them
can invert on its own: the helper derives `DeleteUser`'s boolean from its
own argument (a literal there is the bug), the service's ternary is checked
against the *polarity of its own parameter name* (Panama flipped this once
already, `removeFiles` → `keepFiles`), and the page passes a variable rather
than a constant. Both vocabularies are exercised — `keep`/`remove` and the
legacy `keep-files`/`remove-files` — by reading which refusal comes back,
so a vocabulary that stopped being understood shows up as the wrong message
rather than as no message.
- **`removeIcon` sends an empty path**, and `set_icon` recognizes it rather
than handing "" to GdkPixbuf, which would fail and leave the avatar.
- **The live-validation regression, two ways.** Structurally: the property the
form's user-name test reads must be assigned from a per-keystroke handler,
and the component that assigns it must not be `TextFieldRow`. Bluntly: the
page carries no `TextFieldRow` at all. The page's regex is compared
character for character against the helper's `USERNAME`, and the 31-cap must
appear in the page's own text — a form that accepts a name accountsservice
then refuses has no way to say why.
- **The last administrator is refused helper-side too**, not only by the page
that offers the control.
- **Stock avatars**: shape read from a real run (`{name, path}`, absolute
paths, empty list legitimate).
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (9 handoffs, 39 pages).
The `UsersPage.qml:system-users` exception is deleted; the
`OnlineAccountsPage.qml:online-accounts` one stays with its reason rewritten
to name OAuth specifically — libgoa-backend ships without a GIR binding, so
the provider's own dialog is the only way to obtain a token, and Nextcloud and
IMAP are added on the page itself.
- **`setup/readme-contract` — RUN, PASS** after the count line moved.
### Run with one half relaxed (1)
- **`quickshell/online-accounts-contract` — everything passes except six
input-validation cases**, which are pinned as the spec asks and which the
landed `panama-accounts` does not yet satisfy. See "Still open" below. Run
with those six removed, the contract passes end to end. It is hermetic: `env
-i`, a stub directory first on `PATH` (gdbus/busctl/dbus-send/
gnome-control-center all resolve there, asserted before running), a `gi`
stand-in on `PYTHONPATH` whose `require_version` always raises — so if the
fixture seam were ever removed the contract would stop working rather than
quietly start editing the session's accounts — and both bus addresses pointed
at sockets that do not exist. Accounts come from `PANAMA_ACCOUNTS_FIXTURE`;
what each verb would have done lands in `PANAMA_ACCOUNTS_LOG`. What it pins:
- **Availability is not an error**, at the definition and at every assignment:
no line that decides `available` may mention a write error, and the service
must keep at least two error strings, or "GOA did not answer" and "that one
change did not happen" are the same fact. This is the regression where one
refused toggle replaced four working accounts with "not available".
- **The same thing from the data**: a refused add must come back with the
error *and* the full account list, because the page draws its list from the
same answer that carries the message.
- **The password on stdin, proved rather than assumed.** The log records the
password's **length**, never the thing itself, so the sentinel can be
checked for everywhere it might have gone (stdout, stderr, the log, the
scratch home) while `passwordBytes` proves it was read at all rather than
dropped.
- **The listing cannot carry a credential**, read from `describe`'s own dict
keys and from the GOA properties it reads, so a password property added
later is caught at the source rather than after it reaches the page.
- **An added account is proved, not assumed**: `call_add_account_sync` must be
followed by `call_ensure_credentials_sync`, and `verify` must be able to
`call_remove_sync` — GOA does not check what it is handed, so without that
step a mistyped password produces an account that exists, looks correct, and
never syncs, which is the exact failure this page exists to explain.
- **Provider types**: `owncloud` (the fork kept the id) and `imap_smtp`. A
provider type GOA does not know is not an error — AddAccount is simply never
offered it.
- **Both names for the listing** (`list` and `snapshot`) answer the same
thing, and reading the list changes nothing.
- **Removal carries the path intact** — removing the wrong account is
unrecoverable and looks like a success — and is confirmation-gated on the
page, read from the object block around the call rather than from a
`confirming` property declared anywhere in the file.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — nine entries added, all routing to leaves that
exist in `SettingsRoutes`: **Fingerprint**, **Enroll a fingerprint**, **Delete
a user**, **Account type**, **Reset a password** → `users`; **Nextcloud**,
**Google account**, **Add a mail account**, **Remove an account** →
`accounts`. Checked by evaluating the array: 163 entries, no duplicate labels,
every `page` a real leaf, and none of `settings-search-contract`'s 21 ranked
queries or 9 leaf-routing queries changes its top result — the only new entry
any of them touches at all is Fingerprint, which matches `lock` through
"Unlock" and sits far below the existing first hit.
- No settings docs regenerated: this phase adds no schema keys. Every new
setting is system state (accountsservice, fprintd, authselect, GOA), not a
Panama preference — verified by reading the diff.
### Still open before the run
- **`online-accounts-contract` fails six validation cases against the landed
helper**, and they are left pinned rather than relaxed, because the spec asks
for them and each produces an account that exists, looks right in the list,
and never syncs. `nextcloud_uris` accepts anything with a host once `https://`
is prepended, and `imap_account` accepts any non-empty string as a server:
- `add-nextcloud` accepts `not a url`, `https://cloud.example.org; reboot`,
and `https://cloud example org`;
- `add-imap` accepts `imap example com`, `imap.example.com; reboot`, and
`-imap.example.com` as either server.
A hostname check in `nextcloud_uris` and one applied to both hosts in
`imap_account` closes all six. `''`, `ftp://…` and `https://` (no host) are
already refused, and every other assertion in the file passes.
- **`settings-search-contract` has not been run**: it starts a Quickshell
harness. The nine new entries were checked statically as described above.
- **`settings-pages-contract`, `settings-docs-contract`, `settings-jump-contract`
and `settings-buttons-contract` were not run against the two rebuilt pages.**
Six new components landed (LiveFieldRow, SecretFieldRow, PasswordStrengthRow,
StockAvatarPicker, FingerprintEnrollPanel, OnlineAccountRow) and are
registered in `qmldir`, but nothing here has loaded the QML.
- **The enrollment cancel path is pinned only from the source.** The canned
fprintd has no main loop, so `SIGTERM` releasing the device is exercised by
neither half; what is pinned is that the release happens in a `finally` and
that a *failed* enrollment still releases.
- **`user-accounts-contract`'s first half reads the live account service**
(`panama-users snapshot`, `stock-avatars`), so it wants the same quiet moment
the other system contracts do, though nothing it does writes.
- **Seams pinned by name**: `PANAMA_FINGERPRINT_FIXTURE`/`_LOG`,
`PANAMA_ACCOUNTS_FIXTURE`/`_LOG`, and the log shapes
(`{method, arguments}` and `{verb, arguments, passwordBytes}`). Changing
either is meant to be a deliberate act that updates these contracts.
- Run order for this phase: the hermetic ones first (`fingerprint-contract`,
`online-accounts-contract`), then the source-only ones
(`gnome-handoff-contract`, `setup/readme-contract`,
`settings-ownership-contract`, `search-routing-contract`), then the read-only
system one (`user-accounts-contract`), then the harness ones
(`settings-search-contract`), and `settings-pages-contract` last, as before.
## Phase 12 (Privacy & Security) — append below
Spec: `2026-08-24-privacy-security-redesign.md`. Privacy stopped being a page
that read three device rows and pointed at GNOME for everything else. The portal
model went from three hardcoded devices to six tables — camera, microphone,
screencast, remote-desktop, background, location — with screencast and
remote-desktop revoke-only, because their stored value is a structured GVariant
describing a whole session and a switch cannot rebuild one. Traces became
native: a new `panama-privacy` empties the recent-files list and the thumbnail
cache, and the trash row reuses Storage's existing cleanable rather than growing
a second implementation. SSH Keys learned to make a key, with the passphrase
typed at ssh-keygen over a pty. The duplicated Screen-lock card was deleted and
replaced with a pointer.
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase rather than against the spec's
pinned shapes, and four needles moved during it: `speakers` left the permissions
model, the `set` verb became `true|false` rather than `allow|deny`, the
Privacy page's confirm state became `confirmingItem` (with `confirmingRevoke`
and `confirmingTrace` beside it), and the passphrase turned out to travel
through a helper of its own (`type_at_keygen`) rather than inside `generate`.
Each is pinned where it actually landed.
### New contracts (1)
`quickshell/privacy-traces-contract`. The README count line moves
**172 → 173**; `setup/readme-contract` was run and passes ("173 contracts, as
documented").
### Run and passing
- **`quickshell/permissions-contract` — RUN END TO END, PASS.** Rewritten: the
old version set and cleared a camera permission on the REAL portal store
under a probe application id and put it back afterwards. It now runs under
`env -i` against a recording `busctl` first on `PATH` (asserted to resolve
inside the stub directory before anything that writes runs), with
`DBUS_SESSION_BUS_ADDRESS` pointed at a socket that does not exist. What it
pins:
- **No Set path for a structured table, three ways.** By import — the helper
is loaded as a module so the table lists are read at their real values
rather than pattern-matched, because they are derived from a registry and a
contract that understood one spelling of that would be pinning the spelling.
By AST — `SetPermission` is named exactly once in the file, inside one
function, which consults a named list containing `camera`; that list must
not contain `screencast` or `remote-desktop`, and the function must not name
them either. And at runtime — `set screencast …` is refused **and** the call
log gains no `SetPermission`, which is the assertion that survives a
refactor of both of the others.
- **Revoke reaches every remembered token.** The fixture store gives one
application two screencast sessions; the snapshot has to fold them into ONE
row carrying `grants: 2`, and `forget` has to issue two `DeletePermission`
calls. Dropping the first token and leaving the row looking unchanged is the
failure this catches.
- **A path-shaped entry id from the store is dropped, not fed back in.** The
stub's `List` answers with `"../../etc/passwd"` among the tokens; it must
not appear in any subsequent call.
- **Absence is still not failure**: the stub answers `No entry for microphone`
on stderr with a non-zero exit, and the snapshot must report an empty
microphone table with `error: ""`.
- **`speakers` is gone**, pinned as an absence: no portal backend arbitrates
speaker access, so the row was a permanent "nothing has asked" for a
question nobody asks, and listing it made the page look wider than it is.
- **`Permissions.devices` survives** as the camera/microphone compat view, and
something still reads it — ApplicationsPage does, and the symptom of losing
it there is an empty list rather than an error.
- Validated table and application ids, each checked twice: refused *with a
reason*, and having reached no call.
- **`quickshell/privacy-traces-contract` — RUN END TO END, PASS.** New, and
hermetic throughout: `env -i` with `HOME`, `XDG_DATA_HOME` and
`XDG_CACHE_HOME` inside a scratch tree, and nothing that deletes runs until
the helper has reported the fixture's own numbers back (9 × 33333 bytes of
thumbnails, two recent entries). What it pins:
- **Clearing recents empties the file, never unlinks it**, and what is left
parses as XML with `<xbel>` as its root and no bookmarks. A truncation to
zero bytes fails: GTK reads a zero-length file as corrupt and stops
recording history, which looks identical to success.
- **Clearing thumbnails does not follow a link out.** The fixture plants both
traps — a symlink to a file that must survive and a symlinked directory that
must be unlinked rather than descended — and separately points a whole
scratch `~/.cache/thumbnails` at a directory outside the home, which has to
be refused in words.
- **The seam is not a way around the guard**: `PANAMA_PRIVACY_THUMBNAILS`
pointed outside the home is refused exactly as a symlink is, which is what
makes the rest of this file a test rather than a way of disabling the check.
- **One trash implementation**, said three ways: the page reaches
`Disks.clean`/`Disks.cleanables`, the helper's *code* (docstrings excluded by
AST, since they are allowed to say why) never mentions the trash, and
neither does `Traces.qml`.
- **No urgency language in the Traces card**, found by what the card is wired
to rather than by its title, and read only from its string literals.
- **`quickshell/ssh-keys-contract` — RUN END TO END, PASS.** Its read-only half
is unchanged and still runs against the real configuration. The new half runs
under `env -i` with `HOME` in a scratch tree and a recording `ssh-keygen`
first on `PATH`, which logs its own `/proc/$$/cmdline` and `/proc/$$/environ`
and then plays the two passphrase prompts. What it pins:
- **The passphrase never reaches argv or the environment**, asserted against
what the kernel would have shown any other process — and it DOES arrive down
the terminal, twice, or ssh-keygen would have stalled at the second prompt.
It is also absent from the JSON the page parses and from every file under
the scratch tree.
- **By AST, for every function that carries it**, not only `generate`: the pty
write lives in `type_at_keygen`, and a rule that looked only at the caller
would have missed the place the value actually goes. No list literal, no
dict literal, no `open`/`mkstemp`/`write_text`.
- **The environment ssh-keygen runs in**, read back out of `/proc`:
`SSH_ASKPASS_REQUIRE=never` (this desktop sets `prefer`, which draws a
graphical dialog and hangs the pty until the timeout — A found this the hard
way) and `LC_ALL=C`, since the prompts are matched by their words.
- **Overwrite is refused before ssh-keygen runs**, and the existing key is
byte-identical afterwards.
- **Ten bad key names**, each refused and each having reached no ssh-keygen,
with a file outside the SSH directory left untouched and the scratch
directory still holding exactly the two files it should.
- **An empty passphrase needs `--no-passphrase`**, the flag exists, and
nothing in the shell passes it.
- **`fix-permissions` restores 0600 and is confined**: five bad names refused,
and a 0644 file outside the directory still 0644.
- **Removal from the agent is reachable now** — helper verb, service function,
page call, all three — and `ssh-add` was never invoked by the hermetic half.
- **`quickshell/secrets-contract` — RUN END TO END, PASS.** Existing pins kept.
New: **the Copy button is only ever Copy.** The page used to run one
confirmation state for the whole card and label the neighbouring button
`confirming ? "Cancel" : "Copy"`, so arming a Forget replaced the word "Copy"
in the exact place the user had just learnt to find it — and the way out of a
confirmation was to press the button that copies. Pinned from the button
block: its label is the literal `"Copy"`, its handler does not read
`confirming`, and no button both copies and forgets. Also that the copy
confirmation is tied to the row it happened on
(`Keyring.copiedPath === …itemPath`) rather than being one card-wide flag.
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (8 handoffs against 39
pages). `privacy` joins `OWNED`, with the reason: the card that carried the
door was headed "Owned by Fedora" and explained that GNOME's file-history
switches would not take effect in a Hyprland session anyway. Plus the inverse,
said from the page's side — `PrivacyPage.qml` contains no `openGnomePanel` at
all — because the generic loop can only fail on a door that exists.
- **`quickshell/settings-ownership-contract` — RUN, PASS.** The
`lockMinutes`/`lockOnSleep` mirrors onto `privacy` are gone from the expected
table and from the README loop; `modules/settings/README.md` lost the two
mirror rows with them. That was the one mirror in the table nobody had asked
for: Privacy carried a whole second Screen-lock card, so one preference had
two sliders.
- **`setup/readme-contract` — RUN, PASS** ("173 contracts, as documented").
- **Also run and passing, unchanged by this phase but touching the two rebuilt
pages**: `qmldir-registration-contract` (185 components, including B's new
`PrivacyLiveTile` and `SectionLabel`), `search-routing-contract` (144 routed
settings), `settings-nav-contract`, `settings-buttons-contract`,
`settings-docs-contract` (172 settings documented — the schema did not
change, so no docs regeneration was needed), `settings-hardcoded-values-contract`,
`settings-jump-contract`.
### Deferred, and why
- **`quickshell/lock-screen-settings-contract` — NOT RUN.** Its second half
daemonizes a Quickshell preview harness and counts `hyprlock` processes, which
is exactly the kind of thing that should not happen underneath a live desktop
session. Its first half was edited and is source-only: the "Screen lock" card
must not exist on Privacy, `lockMinutes`/`lockMinutesBattery`/`lockOnSleep`
must each be bound on Power and on Privacy nowhere, and Privacy must point at
Power & Lock rather than silently dropping the card. All five were verified by
hand against the landed `PrivacyPage.qml` and `PowerPage.qml`; the harness
half was not exercised.
- **`quickshell/settings-search-contract` — NOT RUN**: it starts a Quickshell
harness. The nine new entries were checked statically — parsed out of
`SettingsSearch.qml` and confirmed to route to `privacy` and `ssh-keys`, both
of which `settings-nav-contract` confirms are leaves.
- **`quickshell/settings-pages-contract` and `settings-write-sweep-contract` —
NOT RUN**: both load the QML. Neither page has been rendered by anything in
this phase.
- **`Traces.qml` is pinned but never loaded.** Its seam
(`PANAMA_PRIVACY_HELPER`) and its lack of a trash path are checked from the
source; no contract here constructs the singleton.
- **A deliberate duplicate label in search.** "Application permissions" now
appears twice — once routing to Applications (what a Flatpak's sandbox
exposes) and once to Privacy (what the portal recorded). They are different
questions that share a name, both cards are titled that, and the dedupe key
includes the page, so both survive. Worth a look on the rendered sidebar.
- **Seams pinned by name**: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`,
`PANAMA_SSH_KEYS_HELPER`, `PANAMA_PRIVACY_RECENTS`,
`PANAMA_PRIVACY_THUMBNAILS`. Both privacy seams re-confine against `HOME`, so
a hermetic run points `HOME` at a scratch tree rather than pointing the seams
out of it; `panama-ssh-keys` reads `Path.home()`, so `HOME` alone redirects it.
- Run order for this phase: the hermetic ones first (`permissions-contract`,
`privacy-traces-contract`, `ssh-keys-contract`), then the source-only ones
(`gnome-handoff-contract`, `settings-ownership-contract`,
`setup/readme-contract`, `search-routing-contract`), then the read-only system
one (`secrets-contract`, which lists the live keyring), then the harness ones
(`settings-search-contract`, `lock-screen-settings-contract`), and
`settings-pages-contract` last, as before.
## Phase 13 (Power & Lock) — append below
Spec: `2026-08-24-power-lock-redesign.md`. The Power page stopped being four
cards that each said a true thing and together said nothing. Two idle cards
holding the same three concepts became one, with a timeline drawn above the
sliders and the ordering warning rendered on it rather than appearing as a
fifth card. The power button became adjustable without root — logind keeps
`HandlePowerKey=ignore`, and the compositor bind branches on a new
`powerButtonAction` preference. The lid's `open` stopped clobbering the panel's
configuration. Battery grew health, and the idle card grew a list of what is
holding the machine awake.
Three agents edited the tree concurrently; everything below was reconciled
against the landed files rather than against the spec's pinned shapes.
### New contracts (1)
`quickshell/power-page-contract`. The README count line moves **173 → 174**;
`setup/readme-contract` was run and passes ("174 contracts, as documented").
The lid emission fix went into the EXISTING `setup/lid-contract` as a fifth
section rather than into a file of its own. It drives the same helper the other
four sections drive, through the same stub directory, and splitting it out
would have meant a second copy of that scaffolding for no separation anyone
benefits from.
### Run and passing
- **`setup/lid-contract` — RUN END TO END, PASS.** Hermetic throughout: a
stubbed `panama-hw` answers the predicates, a stubbed `hyprctl` answers the
monitor query from a fixture and records the rule instead of applying it, and
`XDG_CONFIG_HOME` points at a scratch settings store. Nothing reached the
compositor. The four existing sections are unchanged and still pass. The new
one pins:
- **The full record survives the round trip.** A fixture panel with mode,
scale, transform, stored x/y, VRR, bit depth, colour profile and both SDR
trims comes back as an `hl.monitor` rule carrying every one of them, with
`position` built from the stored coordinates rather than `"auto"`.
- **The old four-key emission fails it**, verified by running the check
against the pre-fix `cmd_open` before A's rewrite landed: eight findings,
naming the position and every extended field it dropped.
- **The two failure directions stay different**, which is the part worth a
contract. A bad colour profile, VRR, bit depth or SDR value drops on its
own and the geometry stands; a bad transform, scale or mode refuses the
WHOLE record and the panel comes back on `mode = "preferred"`, never
half-honoured. Each of the three geometry fixtures was confirmed to be
valid JSON differing from the good one in exactly one field, so none of
them passes by failing to parse.
- **No stored coordinates means `position = "auto"`**, and a half-written
position (an `x` with no `y`) is refused the same way `monitors.lua`
refuses it.
- **`null` never reaches the rule** — jq prints an absent key as the string
"null", and a rule carrying one is the shape this whole fix exists to avoid.
- **It stays `hyprctl eval`, never `keyword`**, so even a wrong rule dies at
the next reload.
- **`quickshell/battery-contract` — RUN END TO END, PASS.** Existing pins
unchanged; still driven against fixture sysfs trees. New: health is read
from `energy_full`/`energy_full_design` AND from the `charge_` spelling (a
driver that reports charge is not a machine without a battery), the cycle
count is carried through, and — the half that matters — a battery whose
firmware reports neither gets `null`, not a confident `100%` and `0 cycles`
on hardware that never said. Checked four ways: the laptop fixture without
the files, the desktop fixture with no battery at all, a zero design capacity
that would otherwise divide by it, and the JSON staying parseable in every
one of those cases.
- **`quickshell/power-page-contract` — RUN END TO END, PASS.** Static
apart from one stubbed run: `panama-idle inhibitors` against a fake `busctl`,
which is the only part of the row nothing else covers. What it pins:
- **The power button cannot power the machine off.** Said three ways, because
this is the assertion the redesign turns on: no `systemctl poweroff` on the
bind path (comments stripped first — keybinds.lua explains at length that it
deliberately does not, and a contract must not fail over prose that agrees
with it); the powermenu IPC handler in `shell.qml` runs no command of its
own; and the menu still disarms its destructive entries, which is what makes
powering off two presses.
- **Every option the schema offers is branched on**, with the option values
read OUT of the schema rather than restated — a contract listing the four
would pass on the day a fifth was added and did nothing. Plus: the fallback
arm opens the menu, so a hand-edited settings file cannot decide what the
power key does, and the bind stays `locked = true`.
- **`powerButtonAction` carries no `hypr` block**: it is read by keybinds.lua
the way workspace rules are, and a hypr block would send Hyprland a keyword
that does not exist.
- **One idle card, structurally.** Not by title — the AC and battery sliders
for each of the three timings must live inside the SAME `SettingsCard`
block, sliced by brace depth. A rename cannot satisfy this and a second
card cannot hide from it.
- **The inhibitor list is filtered, ordered, and honest about failure.** A
`shutdown` hold says nothing about whether the screen will blank and is
dropped; sleep, idle and handle-lid-switch holds are kept, Panama's own lid
inhibitor included; `delay` inhibitors sort below `block` ones, because a
delay does not keep a machine awake; and a logind that cannot be reached
exits non-zero WITHOUT printing `[]`, so "could not look" never reads as
"nothing is holding it". The ordering assertion was checked against a
delay-first list to confirm it discriminates.
- **The page's own components count as the page.** The file list is derived —
PowerPage.qml plus every settings component it instantiates that no other
settings page does — so a card lifted into a component of its own keeps
being checked instead of quietly falling out of scope.
- **`hypr/idle-config-contract`, `hypr/idle-defaults-contract`,
`quickshell/powermenu-contract` — RUN, PASS, unchanged.** Verified rather
than assumed: the `inhibitors` verb is additive (it adds no `read_setting`
call, which is what `idle-defaults-contract` walks) and the powermenu's
pre-select argument left `logoutScript` alone.
- **`quickshell/search-routing-contract` — RUN, PASS** (145 routed settings,
up from 144: `powerButtonAction` is the new one). Also RUN and passing:
`schema-hypr-shape-contract` (77 mapped options), `enum-hypr-map-contract`
(11 mapped enums), `preference-schema-contract`,
`settings-ownership-contract`, `setup/readme-contract`.
- **Schema docs and Vicinae commands regenerated and passing.**
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` were
both run without `--check` and their output committed:
`docs/settings.md` goes 172 → **173 settings across 36 groups**, with the new
`power` group rendering as "Found on **Power & Lock**" — the group comment
shape the spec asked C to verify, which comes from the `"power": "power"`
route added to `SettingsSearch.qml`. `settings-power`'s Vicinae keywords
gained "pressing the power button", "hibernate" and "power profile".
`quickshell/settings-docs-contract` — RUN, PASS (173 settings documented);
`quickshell/panama-commands-contract` — RUN, PASS (77 commands).
- **Six search entries added**: Hibernate, Power profile, Power button, Lid,
Suspend, Sleep, all routing to `power`, plus the `"power": "power"` group
route for the new schema group. Hibernate is the one worth having: it has no
control at all, only an honest row saying why this machine will not do it,
and a search that found nothing would read as the desktop having no opinion.
### Deferred, and why
- **`quickshell/settings-search-contract` — NOT RUN**: it daemonizes a
Quickshell harness. The six new entries were checked statically and route to
`power`, which `settings-nav-contract` confirms is a leaf.
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
`qmldir-registration-contract`, `settings-buttons-contract`,
`settings-nav-contract`, `settings-jump-contract`,
`settings-hardcoded-values-contract` — NOT RUN**: they load the QML or drive
the live settings window. The Power page was rebuilt this phase and needs all
of them at the end-of-redesign sweep.
- **`quickshell/lock-screen-settings-contract` — NOT RUN** (same harness reason
as phase 12); its source-only half still expects `lockMinutes`,
`lockMinutesBattery` and `lockOnSleep` bound on Power, which the rebuilt page
keeps.
- **One thing the two agents disagree about, left as it landed.** A's
`IdleLock` exposes `inhibitorsKnown` beside `inhibitors`, so a logind that
could not be reached is distinguishable from one holding nothing — the
helper's own contract pins that distinction, and the verb exits non-zero
without printing `[]` precisely so the caller can tell. B's page does not
read it: an empty list renders as "Nothing — no application holds a wake
lock" whether the probe came back empty or never came back, which the page
argues in a comment is also the honest thing to say before the first reading
lands. That is defensible for the first-read case and wrong for the failure
case, and it is a copy decision rather than a wiring one, so it is reported
rather than pinned. Worth a look on the rendered page.
- **The whole live half of the power button.** Nothing here presses it. The
contract proves the bind branches and that no branch powers off; whether the
compositor picks the new bind up needs a `hyprctl reload`, which this phase
deliberately did not run.
- **Hibernate is dormant on this machine.** `CanHibernate` answers no under
zram swap, so the gated row is pinned by its wiring rather than by having
been seen.
- **Battery health is dormant too**, for the same reason the rest of the
battery surface is: this is a desktop. Every health assertion runs against a
fixture sysfs tree.
- Run order for this phase: the hermetic ones first (`lid-contract`,
`battery-contract`, `power-page-contract`, `idle-config-contract`,
`idle-defaults-contract`), then the source-only ones (`powermenu-contract`,
`search-routing-contract`, `schema-hypr-shape-contract`,
`preference-schema-contract`, `settings-docs-contract`,
`readme-contract`), then the harness ones last.
## Phase 14 (Accessibility) — append below
Spec: `2026-08-24-accessibility-redesign.md`. The Accessibility page stopped
being six cards of controls sorted by mechanism plus one card that apologised,
and became five sorted by what is being accommodated: Vision, Motion, Hearing,
Keyboard & pointer, Screen reader. Behind them, the four things an
accessibility page exists for and this one did not have: the magnifier gained
keybinds routed through the shell, Reduce motion became true of the shell's own
bar, dock, panels and OSD rather than only of the compositor, a visual
alternative to the notification bell arrived, and Orca became a process that is
reported as running or not instead of a button that launched it and forgot.
Invisible and largest: the nine shared row primitives gained screen-reader
names, roles, Tab stops and key handling, which reaches all fourteen categories
at once.
Three agents edited the tree concurrently; everything below was reconciled
against the landed files rather than against the spec's pinned shapes.
### New contracts (1)
`quickshell/accessibility-contract`. The README count line moves **174 → 175**;
`setup/readme-contract` was run and passes ("175 contracts, as documented").
Entirely static, on purpose. Every failure it guards against is one a live run
would report as working: `hyprctl keyword` exits 0 on a Lua-configured Hyprland
while refusing the write, a gsettings `screen-reader-enabled` write succeeds and
starts nothing, and a row with no `Accessible.name` looks and behaves exactly
like one that has it. Nothing here starts a shell, applies a zoom, starts or
stops Orca, or writes a gsettings key.
What it pins, in the order the file argues them:
- **The zoom path commits through the preference and never touches `hyprctl`.**
Asserted over the PATH rather than over `stepZoom`'s own text: the slice is
`stepZoom` plus every function in `Accessibility.qml` it hands work to
(`showZoomOsd`, `zoomLabel`), collected transitively, so splitting the OSD
call into a helper moves the assertions with it rather than out from under
them. Within that path: no `hyprctl`, no `keyword`, a
`SystemSettings.commitPreference` call, an `OsdState` post, and clamp bounds
that either read `PreferenceSchema.spec` or match the schema's own 1.05.0.
The `accessibility` IPC handler in `shell.qml` is checked the same way.
- **The three binds, matched by target and verb rather than by chord.**
`SUPER+=` was already "Reset split", so which free chords the zoom keys take
was a decision made against `keybinds.lua` rather than against the mock — a
contract naming `SUPER+=` would have been wrong on the day it was written.
A separate check fails on any chord bound twice, which is the actual hazard:
Hyprland takes the last bind and the earlier action stops working silently.
- **The visual bell is one-shot.** No `loops: Animation.Infinite`, no `loops:
-1`, no Timer with `repeat: true` anywhere behind it, and it reads
`visualAlerts`.
- **The flash follows bell eligibility EXCEPT the event-sounds gate.** The
signal name is read out of `VisualBell.qml`'s `Connections { target: Notifs }`
rather than guessed, which also pins that the two are really wired. Two
structural checks then run over `Notifs.qml`: no `SoundFeedback.eventSounds`
early return may stand between the enclosing function's first line and the
emit, and the emit must sit on a path that DOES carry the shared gates
(`appRule(...).sound`, low urgency, `suppress-sound`) — otherwise the flash
would not be the bell seen, but a second louder notifier ignoring every
per-application rule somebody set. A third check fails if the file stops
explaining the exception, since an undocumented exception is one the next
reader helpfully removes.
- **Orca is a process, never a gsettings key.** `screen-reader-enabled` is
grepped for repo-wide across the shell's QML and JS, comment-stripped first.
Plus: `orcaRunning` exists, something `pgrep`s, and the probe is not on a
repeating Timer — a poll that runs all session to answer a question nobody
asked is a cost with no reader.
- **The nine primitives.** `Accessible.name`, `Accessible.role` and
`activeFocusOnTab` in each of `SliderRow`, `ToggleRow`, `SwitchRow`,
`ActionRow`, `ChoiceRow`, `SegmentRow`, `SettingRow`, `OptionPickerRow`,
`PickerRow`, resolved through the QML inheritance chain (a name inherited
from `SettingRow` is a name the row really has) — with one exception that is
NOT inheritable: a file drawing its own `MouseArea`/`SettingsToggle`/
`SettingsButton`/`ValueSlider` must declare its own `activeFocusOnTab`,
because `SettingRow` is a Tab stop only when `activatable`, which a
`ToggleRow` is not. Then: Space/Enter activation on the four rows that
activate, Left/Right on the two that hold a range, a focus indicator gated on
`activeFocus`, and — the visual-at-rest promise — no focus border painted
unconditionally, since these rows are used by every page.
- **The page, per the approved mock.** The five card titles, the five retired
ones absent, `dimStrength` gated on `dimInactive`, the mono-audio row present
and marked not-yet with somewhere to go meanwhile, the Keyboard jump going
through `openSettings()` rather than assigning `settingsPage` by hand, and no
`Process` on the page or its own components.
- **The honesty box is a statement, not an alarm.** It must name Hyprland (not
"Wayland"), say the absence was probed rather than assumed, and explain why
the GNOME switches are inert here — and it must NOT be painted in
`Theme.danger`/`Theme.warn` or use the words Warning, Error, Unsupported,
unfortunately, sorry, Broken. An accessibility page is the last page that can
afford to open with an alarm.
- **The dead zero label, swept across every settings page rather than only this
one.** `SliderRow.display()` substitutes `zeroLabel` at exactly 0, so a row
setting one for a setting whose schema minimum is above 0 is copy that can
never appear — which is what `zeroLabel: "Off"` was doing under a magnifier
whose minimum IS 1.0. Any page reintroducing that class of bug now fails.
- **Reduce motion is true of the shell**: `Theme.motionEnabled` reads
`Settings.animationsEnabled`, and all four duration tokens collapse to 0.
- **The cross-goal collision, which is silent.** `VisualBell` must not time its
flash with a `Theme.dur*` token: those now collapse to 0 under Reduce motion,
so a flash timed with `Theme.durFast` would be instantaneous and therefore
invisible — switching on Reduce motion would silently switch OFF Visual
alerts, for somebody quite likely to want both. A landed and pinned.
### Reconciled
- **`quickshell/notification-app-rules-contract` — RUN, PASS after a fix.** It
broke on the visual-alerts refactor: the bell's shared gates moved out of
`playBell` into the new `bellWouldRing` predicate, and the contract read only
`playBell`'s literal body, so it reported that the per-application sound
switch and the effective-urgency check had been dropped when both had merely
moved one call up. Now asserted over the bell's DECISION PATH (`playBell` +
`bellWouldRing`), plus a new assertion that `playBell` actually consults the
predicate — without which every gate would sit in a function nothing runs,
which reads exactly like a passing contract.
- **`quickshell/osd-model-contract` — RUN, PASS.** Gained the magnifier's OSD
kind: `iconFor('zoom', 0.4)` is `zoom-in-symbolic` and `iconFor('zoom', 0)` is
`zoom-original-symbolic`, since an empty bar is 1.00 ×, which is off rather
than barely magnified. Without a mapping the kind falls through to its own
name and the OSD draws the generic fallback glyph — for the one shortcut
whose whole job is telling somebody who cannot read the screen what the
magnification now is.
- **No contract pinned the Theme duration literals.** Checked by grep across
`tests/` for `durFast`/`durNormal`/`durSlow`/`durDockReveal` and
`motionEnabled`: zero hits before the change, so gating them broke nothing.
`accessibility-contract` now pins the gating itself, which is the first time
those tokens have been pinned anywhere.
- **`quickshell/settings-ownership-contract` — RUN, PASS unchanged.** Its
duplicate table expects exactly `animationsEnabled`, `cursorInactiveTimeout`,
`cursorSize` and `inactiveOpacity` to appear on two pages, and the rebuilt
page keeps all four, so the mirror set did not move.
### Seven search entries added
Magnifier zoom, Zoom in and out, Reduce motion, Visual alerts, Screen reader,
Orca, Sticky keys — all routing to `accessibility`. The schema already covers
the switches by their own labels, so these are only the words people arrive
with that no label uses: the verb rather than the noun ("zoom in", not
"Magnifier"), the application's name rather than its category ("Orca", not
"Screen reader"), and the schema's own wording gap ("Reduce motion", where the
label reads "Animations"; "Visual alerts", where it reads "Flash the screen for
notifications").
**High contrast was deliberately NOT added**, and the contract fails if it is:
it is already a schema label, and the index covers every schema label, so a
second copy would list the same setting twice in one result. The contract also
fails if any of these entries routes anywhere but `accessibility`.
### Schema docs and launcher commands regenerated
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
run without `--check` and their output committed. `docs/settings.md` goes 173 →
**174 settings across 36 groups**: `visualAlerts` renders in the accessibility
group, and `magnifierFactor` picks up its new `×` unit in both the default
column ("1.0 ×") and the detail ("1.00 × is off"). `settings-accessibility`'s
Vicinae keywords gained "flash the screen for notifications", "magnifier zoom",
"zoom in and out", "reduce motion" and "visual alerts".
`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented);
`quickshell/panama-commands-contract` — RUN, PASS (77 commands; the
settings generator writes 38 of them).
### Deferred, and why
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
`settings-search-contract`, `settings-buttons-contract` (live half),
`settings-preferences-contract`, `settings-commit-reset-contract`,
`settings-system-contract`, `settings-hyprland-write-contract` — NOT RUN**:
they daemonize a Quickshell harness or drive the live settings window. The
Accessibility page was rebuilt this phase, `SettingsNote.qml` is new, and all
nine shared row primitives changed, so this set is the end-of-redesign sweep
and matters more here than it did in any previous phase — a broken
`SliderRow` takes every settings page down, and only these would see it.
`qmldir-registration-contract` (static) was run and passes, so
`SettingsNote` at least resolves.
- **The whole live half of the zoom keybinds.** Nothing here presses a key. The
contract proves the binds exist, name the IPC verb, sit on unoccupied chords,
and that the path behind them commits through the preference; whether
Hyprland picks the new binds up needs a `hyprctl reload`, which this phase
deliberately did not run.
- **The flash has not been seen.** `VisualBell` is pinned structurally — one
animation, no loops, no repeating timer, gated on the preference, not timed
with a motion-gated token — but nobody has watched a notification arrive with
Visual alerts on. That is the one assertion here a screenshot would settle
and a grep cannot.
- **Orca has not been started or stopped.** The service is pinned as a process
probe with on-demand polling; whether `pgrep -x orca` matches the installed
Orca is A's verified claim in a comment (it calls `set_process_name("orca")`,
which renames `comm`), not something this contract re-checks.
- **The screen reader has not read the page.** `Accessible.name`, `role` and
`activeFocusOnTab` are present in all nine files and the key handlers are
there, but no AT-SPI client has walked the settings window to confirm the
names come out in a useful order. That needs Orca running against a live
shell.
- **`quickshell/sound-page-contract` fails, and it is not this work.** Verified
by stashing the whole working tree and running it against a clean checkout:
it fails identically ("DictationPage does not report whether the speech
server is installed"), so it was already red before this phase began.
- Run order for this phase: the static ones first (`accessibility-contract`,
`osd-model-contract`, `preference-schema-contract`,
`schema-hypr-shape-contract`, `settings-ownership-contract`,
`search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`,
`settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`),
then the sandboxed-harness ones, then the live settings window last.