462 lines
42 KiB
Markdown
462 lines
42 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.
|