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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user