Give Sound the whole story, and keep the buttons inside the card

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -7,7 +7,7 @@ with Gabriel's go-ahead**, and failures get fixed then.
## The run
- `panama test` — the full suite (**166** contracts as of the phase 4 Shell
- `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`).
@@ -249,3 +249,99 @@ re-checked against their files as those landed:
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.