Draw idle as one timeline, and let the power button answer to its owner

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 20:33:44 -04:00
parent 6f0ce639d9
commit e1ff25fc66
23 changed files with 2655 additions and 174 deletions
@@ -1302,3 +1302,171 @@ documented").
one (`secrets-contract`, which lists the live keyring), then the harness ones
(`settings-search-contract`, `lock-screen-settings-contract`), and
`settings-pages-contract` last, as before.
## Phase 13 (Power & Lock) — append below
Spec: `2026-08-24-power-lock-redesign.md`. The Power page stopped being four
cards that each said a true thing and together said nothing. Two idle cards
holding the same three concepts became one, with a timeline drawn above the
sliders and the ordering warning rendered on it rather than appearing as a
fifth card. The power button became adjustable without root — logind keeps
`HandlePowerKey=ignore`, and the compositor bind branches on a new
`powerButtonAction` preference. The lid's `open` stopped clobbering the panel's
configuration. Battery grew health, and the idle card grew a list of what is
holding the machine awake.
Three agents edited the tree concurrently; everything below was reconciled
against the landed files rather than against the spec's pinned shapes.
### New contracts (1)
`quickshell/power-page-contract`. The README count line moves **173 → 174**;
`setup/readme-contract` was run and passes ("174 contracts, as documented").
The lid emission fix went into the EXISTING `setup/lid-contract` as a fifth
section rather than into a file of its own. It drives the same helper the other
four sections drive, through the same stub directory, and splitting it out
would have meant a second copy of that scaffolding for no separation anyone
benefits from.
### Run and passing
- **`setup/lid-contract` — RUN END TO END, PASS.** Hermetic throughout: a
stubbed `panama-hw` answers the predicates, a stubbed `hyprctl` answers the
monitor query from a fixture and records the rule instead of applying it, and
`XDG_CONFIG_HOME` points at a scratch settings store. Nothing reached the
compositor. The four existing sections are unchanged and still pass. The new
one pins:
- **The full record survives the round trip.** A fixture panel with mode,
scale, transform, stored x/y, VRR, bit depth, colour profile and both SDR
trims comes back as an `hl.monitor` rule carrying every one of them, with
`position` built from the stored coordinates rather than `"auto"`.
- **The old four-key emission fails it**, verified by running the check
against the pre-fix `cmd_open` before A's rewrite landed: eight findings,
naming the position and every extended field it dropped.
- **The two failure directions stay different**, which is the part worth a
contract. A bad colour profile, VRR, bit depth or SDR value drops on its
own and the geometry stands; a bad transform, scale or mode refuses the
WHOLE record and the panel comes back on `mode = "preferred"`, never
half-honoured. Each of the three geometry fixtures was confirmed to be
valid JSON differing from the good one in exactly one field, so none of
them passes by failing to parse.
- **No stored coordinates means `position = "auto"`**, and a half-written
position (an `x` with no `y`) is refused the same way `monitors.lua`
refuses it.
- **`null` never reaches the rule** — jq prints an absent key as the string
"null", and a rule carrying one is the shape this whole fix exists to avoid.
- **It stays `hyprctl eval`, never `keyword`**, so even a wrong rule dies at
the next reload.
- **`quickshell/battery-contract` — RUN END TO END, PASS.** Existing pins
unchanged; still driven against fixture sysfs trees. New: health is read
from `energy_full`/`energy_full_design` AND from the `charge_` spelling (a
driver that reports charge is not a machine without a battery), the cycle
count is carried through, and — the half that matters — a battery whose
firmware reports neither gets `null`, not a confident `100%` and `0 cycles`
on hardware that never said. Checked four ways: the laptop fixture without
the files, the desktop fixture with no battery at all, a zero design capacity
that would otherwise divide by it, and the JSON staying parseable in every
one of those cases.
- **`quickshell/power-page-contract` — RUN END TO END, PASS.** Static
apart from one stubbed run: `panama-idle inhibitors` against a fake `busctl`,
which is the only part of the row nothing else covers. What it pins:
- **The power button cannot power the machine off.** Said three ways, because
this is the assertion the redesign turns on: no `systemctl poweroff` on the
bind path (comments stripped first — keybinds.lua explains at length that it
deliberately does not, and a contract must not fail over prose that agrees
with it); the powermenu IPC handler in `shell.qml` runs no command of its
own; and the menu still disarms its destructive entries, which is what makes
powering off two presses.
- **Every option the schema offers is branched on**, with the option values
read OUT of the schema rather than restated — a contract listing the four
would pass on the day a fifth was added and did nothing. Plus: the fallback
arm opens the menu, so a hand-edited settings file cannot decide what the
power key does, and the bind stays `locked = true`.
- **`powerButtonAction` carries no `hypr` block**: it is read by keybinds.lua
the way workspace rules are, and a hypr block would send Hyprland a keyword
that does not exist.
- **One idle card, structurally.** Not by title — the AC and battery sliders
for each of the three timings must live inside the SAME `SettingsCard`
block, sliced by brace depth. A rename cannot satisfy this and a second
card cannot hide from it.
- **The inhibitor list is filtered, ordered, and honest about failure.** A
`shutdown` hold says nothing about whether the screen will blank and is
dropped; sleep, idle and handle-lid-switch holds are kept, Panama's own lid
inhibitor included; `delay` inhibitors sort below `block` ones, because a
delay does not keep a machine awake; and a logind that cannot be reached
exits non-zero WITHOUT printing `[]`, so "could not look" never reads as
"nothing is holding it". The ordering assertion was checked against a
delay-first list to confirm it discriminates.
- **The page's own components count as the page.** The file list is derived —
PowerPage.qml plus every settings component it instantiates that no other
settings page does — so a card lifted into a component of its own keeps
being checked instead of quietly falling out of scope.
- **`hypr/idle-config-contract`, `hypr/idle-defaults-contract`,
`quickshell/powermenu-contract` — RUN, PASS, unchanged.** Verified rather
than assumed: the `inhibitors` verb is additive (it adds no `read_setting`
call, which is what `idle-defaults-contract` walks) and the powermenu's
pre-select argument left `logoutScript` alone.
- **`quickshell/search-routing-contract` — RUN, PASS** (145 routed settings,
up from 144: `powerButtonAction` is the new one). Also RUN and passing:
`schema-hypr-shape-contract` (77 mapped options), `enum-hypr-map-contract`
(11 mapped enums), `preference-schema-contract`,
`settings-ownership-contract`, `setup/readme-contract`.
- **Schema docs and Vicinae commands regenerated and passing.**
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` were
both run without `--check` and their output committed:
`docs/settings.md` goes 172 → **173 settings across 36 groups**, with the new
`power` group rendering as "Found on **Power & Lock**" — the group comment
shape the spec asked C to verify, which comes from the `"power": "power"`
route added to `SettingsSearch.qml`. `settings-power`'s Vicinae keywords
gained "pressing the power button", "hibernate" and "power profile".
`quickshell/settings-docs-contract` — RUN, PASS (173 settings documented);
`quickshell/panama-commands-contract` — RUN, PASS (77 commands).
- **Six search entries added**: Hibernate, Power profile, Power button, Lid,
Suspend, Sleep, all routing to `power`, plus the `"power": "power"` group
route for the new schema group. Hibernate is the one worth having: it has no
control at all, only an honest row saying why this machine will not do it,
and a search that found nothing would read as the desktop having no opinion.
### Deferred, and why
- **`quickshell/settings-search-contract` — NOT RUN**: it daemonizes a
Quickshell harness. The six new entries were checked statically and route to
`power`, which `settings-nav-contract` confirms is a leaf.
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
`qmldir-registration-contract`, `settings-buttons-contract`,
`settings-nav-contract`, `settings-jump-contract`,
`settings-hardcoded-values-contract` — NOT RUN**: they load the QML or drive
the live settings window. The Power page was rebuilt this phase and needs all
of them at the end-of-redesign sweep.
- **`quickshell/lock-screen-settings-contract` — NOT RUN** (same harness reason
as phase 12); its source-only half still expects `lockMinutes`,
`lockMinutesBattery` and `lockOnSleep` bound on Power, which the rebuilt page
keeps.
- **One thing the two agents disagree about, left as it landed.** A's
`IdleLock` exposes `inhibitorsKnown` beside `inhibitors`, so a logind that
could not be reached is distinguishable from one holding nothing — the
helper's own contract pins that distinction, and the verb exits non-zero
without printing `[]` precisely so the caller can tell. B's page does not
read it: an empty list renders as "Nothing — no application holds a wake
lock" whether the probe came back empty or never came back, which the page
argues in a comment is also the honest thing to say before the first reading
lands. That is defensible for the first-read case and wrong for the failure
case, and it is a copy decision rather than a wiring one, so it is reported
rather than pinned. Worth a look on the rendered page.
- **The whole live half of the power button.** Nothing here presses it. The
contract proves the bind branches and that no branch powers off; whether the
compositor picks the new bind up needs a `hyprctl reload`, which this phase
deliberately did not run.
- **Hibernate is dormant on this machine.** `CanHibernate` answers no under
zram swap, so the gated row is pinned by its wiring rather than by having
been seen.
- **Battery health is dormant too**, for the same reason the rest of the
battery surface is: this is a desktop. Every health assertion runs against a
fixture sysfs tree.
- Run order for this phase: the hermetic ones first (`lid-contract`,
`battery-contract`, `power-page-contract`, `idle-config-contract`,
`idle-defaults-contract`), then the source-only ones (`powermenu-contract`,
`search-routing-contract`, `schema-hypr-shape-contract`,
`preference-schema-contract`, `settings-docs-contract`,
`readme-contract`), then the harness ones last.