1907 lines
139 KiB
Markdown
1907 lines
139 KiB
Markdown
# 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.0–5.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.
|
||
|
||
## Phase 15 (System) — the last one
|
||
|
||
Spec: `2026-08-24-system-redesign.md`. System went from ten tabs to eight:
|
||
Region & Language merged into **Date, Time & Region** — a date format and the
|
||
clock that shows it are one subject, and splitting them meant changing how a
|
||
date is written on a page that never showed one — and the Manual became a
|
||
routable leaf with no tab, opened from About's Manual card, a deep link, or the
|
||
search box rather than found by scanning a strip. With that, the settings
|
||
redesign is complete: fourteen categories, thirty-eight leaves, every one of
|
||
them rebuilt.
|
||
|
||
The bug list this phase carried was longer than the layout change. The
|
||
hardcoded Quickshell version, the auto-download switch nested inside the
|
||
Firmware card, `CHECK_TITLES` missing `panama.updates`, two Health actions with
|
||
no target, RegionPage's stale "Open appearance" handoff, `panama-updates`'
|
||
stale dnf-automatic comment, the backup colour gap, Restore without
|
||
confirmation, and four pieces of dead code.
|
||
|
||
Three agents edited the tree concurrently; everything below was reconciled
|
||
against the landed files rather than against the spec's pinned shapes, and
|
||
every contract named here was **run**.
|
||
|
||
### No new contracts (175 → 175)
|
||
|
||
Every pin fitted a file that already existed, so the suite did not grow. That
|
||
was the judgement call this phase offered and it went the honest way: a
|
||
`system-pages-contract` would have been a new file holding assertions that
|
||
belong beside the ones they are variations of — the nav counts beside the nav
|
||
taxonomy, the repair command beside the repair, the single-check verb beside
|
||
the scan. `setup/readme-contract` — RUN, PASS ("175 contracts, as documented").
|
||
|
||
### What each contract gained, and why
|
||
|
||
- **`quickshell/settings-nav-contract` — RUN, PASS** (14 categories, 38 leaves
|
||
of which 1 hidden, 3 retired ids). It learned the third leaf shape:
|
||
`SettingsRoutes.hiddenLeaves`, which is how the manual stays addressable
|
||
without a tab. A hidden leaf that is also a tab, or also a category, now
|
||
fails — that overlap is the one the existing checks could not see, because
|
||
the hidden half draws no row anywhere. Then: the System strip is pinned at
|
||
exactly eight tabs in order (the number IS the point — it is the horizontal
|
||
space one row has, and an eleventh subject needs a decision, not another
|
||
entry), `region` is pinned to resolve specifically to `datetime` rather than
|
||
merely to *a* leaf, `RegionPage.qml` must be gone, and the manual must be a
|
||
leaf and must not be a tab.
|
||
It also grew the check that would have caught the retired search entries:
|
||
every page id in `SettingsSearch` — extra entries and group routes alike —
|
||
must be a live leaf, with a distinct failure message when it is a retired id,
|
||
since that is the case that still opens a window and lands somewhere else.
|
||
Plus a table of the fifteen subjects the consolidation moved, each asserted
|
||
to be findable by its own name on the tab that now owns it.
|
||
- **`quickshell/settings-pages-contract` — RUN, PASS** (production PIDs
|
||
preserved). `Updates`, `DateTime`, `Containers` and `Manual` joined the
|
||
page-scaffold sweep; Containers is the one that was waiting for it, since its
|
||
root was a bare `Item` and every convention the scaffold carries was
|
||
hand-rolled there. The live routing list gained `updates`, `datetime` and
|
||
`containers`, and the retired `region` id is now driven through the real IPC
|
||
and asserted to land on `datetime`.
|
||
- **`quickshell/updates-contract` — RUN, PASS** (2 dnf, 3 flatpak, 0 firmware).
|
||
Three additions. **Download sizes add up or are not offered**: a total is
|
||
present only when every pending item was priced, because a partial figure
|
||
shown as the download understates it, and understating it is the direction
|
||
that costs somebody money on a metered connection. **A changelog is a read**,
|
||
proved by construction rather than by inspection: `dnf5`, `flatpak`,
|
||
`fwupdmgr`, `pkexec` and `systemctl` are replaced with stubs that record
|
||
their argv and answer nothing, and the recorded argv is then checked for
|
||
mutating tokens — which also exercises the honest-absence path, the common
|
||
case on a machine with third-party repositories. Names outside
|
||
`NAME_PATTERN` must be refused before anything is launched. **One
|
||
application, by name**: the per-app argv is asserted exactly
|
||
(`flatpak update -y --noninteractive <id>`) against a stub, and an ID the
|
||
last scan did not list must be refused without running flatpak at all.
|
||
- **`quickshell/panama-doctor-contract` — RUN, PASS.** The `CHECK_TITLES`
|
||
KeyError is pinned twice: as the invariant (every id in `CHECK_ORDER` has a
|
||
title, asserted by importing the module) and as the path (a probe forced to
|
||
raise must come back as a titled warning row rather than taking the report
|
||
with it). Worth both — the bug needed the updates probe to fail, which it
|
||
almost never does, and the containment that was supposed to catch it *was*
|
||
the KeyError, raised inside the `except` handling the original failure.
|
||
Then: the updates action's `target: "updates"`, driven by a fixture cache
|
||
with a security count; `repairCommand` asserted against `REPAIR_COMMANDS`
|
||
itself rather than restated, so an argv change cannot leave the row
|
||
describing the old one; and the `check CHECK_ID` verb — full snapshot
|
||
envelope, exactly one check, summary arithmetic covering only that check,
|
||
refusal of unauthored ids with no report and no filesystem mutation, and no
|
||
second output mode.
|
||
The fixture also gained `XDG_CACHE_HOME`, which was leaking: `check_updates`
|
||
reads the Updates cache from the environment rather than through
|
||
`DoctorConfig`, so the contract had been reading the real machine's pending
|
||
updates.
|
||
- **`quickshell/health-service-contract` — RUN, PASS.** The both-sides pin is
|
||
derived from both files: every `open`/`instructions` target the doctor emits
|
||
must appear in Health's `settingsTargets`/`instructionTargets`. This is the
|
||
one where half a change does not degrade a row, it blanks the page —
|
||
`validAction` false makes `validCheck` false makes `consumeSnapshot` reject
|
||
the whole report. Live fixtures either side of it: a check targeting
|
||
`updates` is accepted intact, and one targeting `storage` — a real page the
|
||
list deliberately omits — is rejected, which is what makes the allow-list an
|
||
allow-list. Also `repairCommand` surviving the projection (a new known key is
|
||
dropped silently unless it is added, and the failure is invisible: the repair
|
||
still works, only the sentence saying what it runs is gone), a check without
|
||
one still accepted, `refreshCheck` asking for `check <id>` and never `--json`
|
||
while leaving the other rows and recomputing the headline counts, an unknown
|
||
id starting no process, and `saveReport` writing the same redacted projection
|
||
to a named file.
|
||
- **`quickshell/health-ui-contract` — RUN, PASS.** The Colour profiles handoff
|
||
assertion is inverted rather than deleted (Displays owns colour profile, bit
|
||
depth, SDR brightness and saturation per output, which is more than GNOME's
|
||
panel can say in a session it does not manage); Digital wellbeing stays. The
|
||
repair command must be shown, and must not be gated on `lastRepair`,
|
||
`repairingId` or a working state — after the fact is not the same answer,
|
||
because by then the decision is made. A per-row re-check and a save-report
|
||
row must exist. The fixture grew a `panama.updates` check whose action is
|
||
followed for real through `requestHealthAction`, landing on `updates`: the
|
||
third piece of the chain the doctor and the service each pin one end of.
|
||
- **`quickshell/settings-backup-contract` — RUN, PASS.** Named snapshots
|
||
(fifteen rows reading `2026-08-24 11:03:07` are fifteen rows nobody can
|
||
choose between), with the name reaching a filename — which is the oldest way
|
||
a helper gets talked into writing outside its own directory. Seven hostile
|
||
names, each of which must either fail or produce a file inside the backup
|
||
directory. Delete gets the same confinement as restore in the other
|
||
direction, and a worse failure mode: restore reading the wrong file
|
||
overwrites settings, delete resolving the wrong name destroys something that
|
||
is not a backup. Traversal, absolute paths, an escaping symlink, an
|
||
already-deleted name and an empty name all refused. `bytes` per row, checked
|
||
against `stat`.
|
||
- **`quickshell/settings-backup-live-contract` — RUN, PASS.** The colour gap,
|
||
both halves. `layoutFromStoredDisplays` must carry `vrrMode`,
|
||
`colorProfile`, `bitdepth`, `sdrBrightness`, `sdrSaturation` and `mirrorOf`
|
||
through, and must still accept a record written before they existed.
|
||
`layoutsEqual` must detect a difference in each of them — **this is the
|
||
assertion the shipped behaviour fails**, and the reason the restore silently
|
||
did nothing: the seven-field comparison called a snapshot with different
|
||
colour settings equal, so the restore took the early return and reported
|
||
success while leaving HDR off. Plus the inverse for the two float fields: a
|
||
value that came back one ulp different is the same value, or every restore
|
||
reapplies the layout it already has.
|
||
Two harness seams were added for this (`layoutFor`, `layoutsMatch`), because
|
||
the colour half is invisible from `handleRestoreOutput` — geometry restores
|
||
perfectly and success is reported either way.
|
||
- **`quickshell/settings-sync-contract` — RUN, PASS.** The preview's `changes`
|
||
must be `{key, from, to}` with both sides already text, a `changeCount`
|
||
agreeing with what would be applied, a rendered list no longer than that
|
||
count, and no side over 200 characters — with a bundle carrying a 4000-
|
||
character `weatherLocation` proving the cap is real rather than incidental.
|
||
Stringifying in the helper rather than in QML is what makes the cap
|
||
enforceable at all. Observed on this machine: 115 changes, 40 rendered,
|
||
absent values rendered as "not set".
|
||
- **`quickshell/manual-contract` — RUN, PASS** (5 chapters). Titles now come
|
||
from the files: the chapter list moved to `ManualChapters.qml`, which both
|
||
the reader and About's card instantiate, and the contract fails on any
|
||
`label:` beside a filename — the drift it is guarding against had already
|
||
happened, under a comment claiming it could not. It also fails if either page
|
||
names chapter files itself. In-app links: the handler must route
|
||
`panama://settings/<page>` through `ShellState.openSettings` while still
|
||
sending everything else out of the desktop, asserted by following the call
|
||
(inline block or named function, brace-matched) rather than by reading one
|
||
line, and every such link written in a chapter must name a real leaf. Plus
|
||
the taxonomy check inverted: the manual must be a hidden leaf and must NOT be
|
||
a System tab.
|
||
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (6 handoffs checked
|
||
against 37 pages). Two panels joined `OWNED`: `system region`, because Date,
|
||
Time & Region now offers a language picker, four per-category format
|
||
dropdowns backed by the installed locales, and a live preview of what each
|
||
renders; and `color`, because Displays has offered a colour profile per
|
||
output for some time. Both are derived rather than hand-asserted, so the door
|
||
cannot come back under a name nobody thought to list. Plus two inverses:
|
||
`RegionPage.qml` must stay gone, and `DateTimePage` must not open a GNOME
|
||
panel for something it now does itself.
|
||
- **`quickshell/search-routing-contract` — RUN, PASS** (146 routed settings).
|
||
Unchanged; run because `SettingsSearch` moved.
|
||
|
||
### Search entries
|
||
|
||
Two moved: Language and Regional formats, from `region` to `datetime`. Fifteen
|
||
added: the seven About facts nobody could search for (Hostname, Kernel version,
|
||
Device model, Installed memory, Uptime, Serial number, BIOS version — none of
|
||
them the label of a preference, all of them things people arrive looking for by
|
||
name, and "Installed memory" rather than "Memory" because that one IS already a
|
||
schema label on Bar); the two Sync verbs (Export settings, Import settings —
|
||
"Carry settings to another machine" was the only entry, and nobody searches for
|
||
a sentence); and six format subjects on the merged tab (Date format, Number
|
||
format, Currency, Measurement units, Paper size, First day of the week) plus
|
||
"Set the clock by hand".
|
||
|
||
### Schema docs and launcher commands regenerated
|
||
|
||
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
|
||
run without `--check` and their output committed. `docs/settings.md` stays at
|
||
**174 settings across 36 groups** — no preference was added this phase — but
|
||
the `datetime` group is now "Found on **System › Date, Time & Region**", which
|
||
is the whole point of a generated document: the tab was renamed in one place.
|
||
|
||
The launcher went 38 generated commands to **37**: `settings-region` was
|
||
removed, and `settings-manual` survives because the generator learned about
|
||
`hiddenLeaves` — a leaf with no tab is the one leaf that cannot be found by
|
||
scanning, so it is the one that most needs a launcher entry. Keywords picked up
|
||
the new search entries on their own: `settings-about` gained hostname, kernel
|
||
version, device model, installed memory, uptime, serial number and BIOS
|
||
version; `settings-datetime` gained language, regional formats and the six
|
||
format subjects; `settings-sync` gained export and import.
|
||
|
||
`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented).
|
||
`quickshell/panama-commands-contract` — RUN, PASS (76 commands), after teaching
|
||
its own leaf derivation about `hiddenLeaves` too: it counted 75 and found 76,
|
||
which is the generator and the contract disagreeing about what a leaf is
|
||
rather than a stale command. `quickshell/qmldir-registration-contract` — RUN,
|
||
PASS (189 components), covering the two new ones, `ManualChapters` and
|
||
`FieldActionRow`.
|
||
|
||
### Deferred, and why
|
||
|
||
- **`quickshell/settings-search-contract` — NOT RUN.** It daemonizes a
|
||
Quickshell instance against the user's real XDG directories rather than a
|
||
scratch set, and constructing `SettingsSearch` calls
|
||
`DesktopStyle.ensureStarted()`, which replays application preferences to the
|
||
live GTK configuration. Every case it pins was checked statically instead:
|
||
none of the fifteen new labels equals or prefixes any of the twenty-one
|
||
pinned queries, so none can outrank one, and every schema label is still
|
||
reachable because the schema half of the index did not change. The new static
|
||
page-id sweep in `settings-nav-contract` covers the failure this phase could
|
||
actually have introduced.
|
||
- **Nobody has typed a locale into the four format dropdowns.** The category
|
||
round-trip is A's, through `panama-locale`; no `localectl` write was made
|
||
from any contract here, and none should be.
|
||
- **The manual's in-app links have not been clicked.** The handler is pinned
|
||
structurally and every link a chapter writes is checked against the taxonomy,
|
||
but no chapter currently writes one, so the routing has been proven correct
|
||
and never exercised.
|
||
- **No backup has been restored against the real store**, and no update has
|
||
been applied. Both are pinned through stubs and scratch directories, which is
|
||
the sanctioned path and the only one that should ever run here.
|