Files
Panama/docs/superpowers/specs/2026-08-24-power-lock-redesign.md

9.5 KiB

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.