599 lines
56 KiB
Markdown
599 lines
56 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.
|