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
+9 -1
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
172 settings across 35 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
173 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -289,6 +289,14 @@ Found on **Input Mouse & Touchpad**.
| **Hide pointer while typing**<br>`cursorHideWhileTyping` `cursor:hide_on_key_press` | false | The pointer vanishes on the next keystroke and returns when you move it |
| **Jump pointer to the focused display**<br>`cursorWarpOnWorkspaceChange` `cursor:warp_on_change_workspace` | false | Moves the pointer to the last focused window after switching workspace |
## power
Found on **Power & Lock**.
| Setting | Default | What it does |
|---|---|---|
| **Pressing the power button**<br>`powerButtonAction` | menu | The system ignores the key; Panama decides — so a bumped button never yanks the plug Choices: Shows the power menu, Suspends, Powers off (two-press), Does nothing. |
## search
Found on **Applications Applications**.
@@ -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.
@@ -0,0 +1,140 @@
# Power & Lock redesign — one timeline, honest sleep
Approved mock: `home-mocks/power.html` (scratchpad, :8642). Spec wins over mock on conflict.
Single tabless page stays.
## Goals
1. **Idle timeline**: one visualization (Active → Screen off → Lock → Suspend) above the
sliders, switching wall-power/battery by live source; the ordering warnings become part of
the picture. A "Keeping the machine awake right now" row lists live inhibitors.
2. **Power profile tiles** (existing service; presentation only, "via tuned" honesty).
3. **Battery health**: design-capacity %, charge cycles, and a charge-stop readback tile —
dormant here, real on the laptop.
4. **Power button adjustable without root**: logind keeps `HandlePowerKey=ignore`; the
compositor bind reads a new preference. Options: Shows the power menu (default) / Suspends /
Powers off (two-press: opens the power menu with Power Off pre-selected, so the second press
fires it) / Does nothing.
5. **Honesty rows**: lid card presents its inhibitor design; hibernate row states the zram
reason, gated on the live `CanHibernate` probe; Management shows the generated path.
6. **The `panama-lid` clobber dies**: `cmd_open` emits the full stored record (position from
stored x/y when present, scale, transform, vrr, bitdepth, cm, sdr fields — conditional,
mirroring `monitors.lua`'s validation) instead of the 4-key rule that wipes extended fields.
Non-goals: lid behavior override (deliberately read-only, per LidPolicy's argued design),
scheduled shutdown, suspend-then-hibernate, time-to-empty (deliberate omission stays), per-app
power, conditional suspend rules (hypridle has no conditional listener — the inhibitor row is
the honest substitute).
## Schema (A)
New group `power` (groupPages → `power`): `powerButtonAction` — enum, def `"menu"`, values
`menu | suspend | poweroff | nothing`, label "Pressing the power button", detail per mock. No
hypr block (consumed by keybinds.lua via prefs, like workspace rules — reload-applied is fine
for a power-button preference; if the keybind can read it live via a dispatcher branch,
better — A investigates which pattern keybinds.lua supports and documents the choice).
**Settled (A): per-press, no reload.** The bind is `hl.dsp.exec_cmd` of a one-line shell
`case` over `jq -r '.powerButtonAction // empty'` against the settings file, so the branch is
evaluated when the key goes down rather than when the config is read. All four values branch
inside keybinds.lua; a missing jq, missing file, malformed file or unknown value falls through
to `*)` and opens the menu. `prefs.get` was rejected because prefs.lua loads the store once at
config time, which would have made this the one Power-page control that needs `hyprctl reload`.
## Plumbing (A)
- **`config/dot/hypr/keybinds.lua`**: the `XF86PowerOff` bind branches on the preference:
menu → existing power-menu IPC; suspend → `systemctl suspend`; poweroff → power-menu IPC
with a `poweroff` preselect argument; nothing → no-op bind (still `locked = true`).
- **`shell.qml` + `modules/powermenu/PowerMenu.qml`**: the powermenu IPC accepts an optional
entry id to pre-select (arming Power Off so one more press fires — the existing two-press
semantics preserved); no other menu behavior changes.
- **Settled (A).** Each entry gains a stable `entryId`: `lock | logout | suspend |
hibernate | restart | poweroff`. `qs ipc call powermenu open <entryId>` calls
`PowerMenu.preselect(entryId)`, which opens the menu and calls the entry's own
`trigger()` — the same one a click calls — so a destructive entry arms on the first
press and fires on the second, inside the existing 4-second confirm window. Nothing
bypasses the confirm; an id this machine has no entry for (hibernate without swap) just
opens the menu. `toggle` is unchanged.
- **`scripts/panama-idle`**: new `inhibitors` verb → `[{ who, why, what, mode }]`
(sleep/idle/handle-lid-switch holds only, Panama's own lid inhibitor included honestly);
`services/IdleLock.qml` exposes `inhibitors` + `inhibitorsKnown` + `refreshInhibitors()`,
which B calls on page open.
- **Settled (A), two additions to the pinned shape.** Source is logind's `ListInhibitors`
over `busctl --json=short`, not `systemd-inhibit --list`: that table is padded display
output whose `why` column contains spaces and whose `who` column is a free string the
inhibiting program picks, so no column split is reliable. And each row carries `mode`,
because `delay` and `block` are not the same claim — NetworkManager, UPower and hypridle
hold permanent `delay` holds on every machine, and a list that did not distinguish them
would report the desktop as pinned awake at all times. **Only `block` keeps a machine
awake; B should read the empty state off the blocks, not the row count.** Rows sort blocks
first, then by `who`. On failure the verb prints NOTHING and exits non-zero (never `[]`),
which is what `inhibitorsKnown` is derived from.
- **`scripts/panama-battery`**: `status` gains `healthPercent` (energy_full/energy_full_design
or charge_ equivalents, summed across packs), `cycleCount` — both JSON `null` when sysfs
lacks them, and a reported `cycle_count` of 0 counts as absent rather than as a new battery;
`services/Battery.qml` exposes `healthPercent`/`cycleCount` as `property var` defaulting to
`null` (an `int` property would coerce null back to 0), filled by a new `refreshHealth()`
that runs once when the sysfs paths resolve and again whenever the page asks — never on the
20-second poll.
- **`scripts/panama-lid`**: `cmd_open` reads the stored record's full field set via jq and
emits `hl.monitor({...})` with every valid field, conditional per key, position from stored
x/y when both present else `"auto"` — validation mirroring `monitors.lua` (regex-safe values
only; invalid extended fields drop per-field, geometry survives). Header's "NOT YET VERIFIED
ON A LAPTOP" honesty stays.
## UI (B)
`PowerPage.qml` rebuilt per mock: Power profile tiles (icon, label, one-line detail, active
highlight, degraded note); Idle card (NEW `IdleTimeline.qml`: proportional stops on a
piecewise scale with labels, driven by the same schema values — draggable with snap-to-step
if robust, read-only visualization otherwise [flag which]; the source header "on wall power" /
"on battery" from `Battery.acOnline`; the battery-variant sliders swap in below when on
battery — one card, not two; ordering warnings rendered inline on the timeline rather than a
separate card); Lock before sleeping toggle; inhibitors row ("Nothing — no application holds a
wake lock" empty state); Battery card (health tiles + charge-stop readback + existing
low/critical/action rows; charge-limit slider keeps firmware-confirm detail); Lid card
(read-only, DOCKED/WILL SUSPEND badge from LidPolicy, the inhibitor-design prose); Power
button card (OptionPickerRow on `powerButtonAction`, the mock's detail); Session card (Lock
now — through a service or execDetached as today, hibernate row gated `!canHibernate` showing
the zram reason [read the live probe from PowerMenu's existing CanHibernate source — lift it
into a small service property if needed], power menu pointer row); Management card (managed
toggle + `IdleLock.generatedPath` + service state in the detail).
## Search & docs (C)
Extra entries: Hibernate, Power profile, Power button, Lid, Suspend, Sleep → power. Schema
regen: docs + commands after the new key (orchestrator can also run it; C verifies the group
comment shape).
## Contracts (C — write; hermetic runs only)
- NEW `power-page-contract`: page structure (profile tiles, IdleTimeline present and fed by
the schema keys, one idle card not two, inhibitors row with empty state, hibernate row gated
on the live probe with the zram copy, power button row bound to `powerButtonAction`,
generatedPath rendered); `powerButtonAction` consumed in keybinds.lua (all four values
branch); the poweroff branch goes through the power menu (никогда a direct poweroff — pin
that `systemctl poweroff` appears nowhere in the bind path).
- `lid-contract` / a new static half: fixture settings.json with extended display fields →
the generated `hl.monitor` line carries them; invalid values drop per-field; stored x/y
become position; absent → auto. The old 4-key emission must FAIL it.
- `battery-contract`: health fields present when sysfs provides them, absent-tolerant when
not (desktop fixture), never fabricated.
- `idle-config-contract`/`idle-defaults-contract`: verify unaffected (inhibitors verb is
additive).
- Backlog Phase 13; README count (173 → 174 expected).
## Agent ownership (parallel)
- **A**: `config/PreferenceSchema.qml`, `config/dot/hypr/keybinds.lua`, `shell.qml`,
`modules/powermenu/PowerMenu.qml`, `scripts/panama-idle`, `scripts/panama-battery`,
`scripts/panama-lid`, `services/IdleLock.qml`, `services/Battery.qml`.
- **B**: `modules/settings/PowerPage.qml`, NEW `IdleTimeline.qml`, other components
(+ qmldir).
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
Hard rules: NO live mutations — no systemctl suspend/poweroff, no logind writes, no threshold
writes, no hyprctl keyword/eval, no powermenu triggering; keybinds.lua edits must keep
`hyprctl reload` UNRUN (the user reloads naturally later — but the shell hot-reloads QML, so
PowerMenu/shell.qml edits land live and must stay valid). Read-only probes and stubs only.
B programs against the pinned APIs; A updates this spec before changing them.