Files
Panama/docs/superpowers/specs/2026-08-24-displays-redesign.md
T

143 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Displays redesign — canvas-first, transaction-complete
Approved mock: `home-mocks/displays.html` (scratchpad, served on :8642). This spec is the
implementation contract; where the mock and this file disagree, this file wins.
## Goals
1. **Canvas-first page.** The arrangement canvas is the hero and renders even with a single
display. Everything below it belongs to the selected display; global settings (Night Light,
Gaming, Workspaces) sit in an "All displays" section at the bottom.
2. **More complete.** Per-display VRR override, full color section (profile / bit depth / SDR
trim), hardware (DDC) brightness in the display panel, and mirroring — all riding the existing
keep-or-revert transaction where they are verifiable.
3. **Fix the clobber bug.** `pushLayout` currently emits only five keys, and a Hyprland monitor
rule replaces the previous rule wholesale — so every Settings apply drops `monitors.lua`'s
`bitdepth = 10` / `cm` values (live compositor is 8-bit sRGB today while the page claims
otherwise). Once color fields are part of the record, applies preserve them.
Non-goals: disabling outputs, reserved areas, tearing controls, HDR capability probing from EDID,
per-display wallpaper. The verify/revert machinery keeps its shape: 15 s countdown, exact readback,
generation tags, hotplug revert.
## Extended display record (service API — pinned)
`Displays.qml` per-monitor records gain, alongside `mode/scale/transform/x/y/primary`:
| Field | Type | Values | Hyprland key | Verified via readback? |
|---|---|---|---|---|
| `vrrMode` | int | `-1` follow global policy (default) / `0` off / `1` always / `2` fullscreen | `vrr`**omitted when -1** | **No** — readback `vrr` is live state, not config. Applied, not verified. |
| `colorProfile` | string | `"auto" \| "srgb" \| "wide" \| "hdr"` | `cm` | Yes — `colorManagementPreset` |
| `bitdepth` | int | `8 \| 10` | `bitdepth` | Yes, conservatively — `currentFormat` `XRGB8888`→8, `XRGB2101010`→10; any other format skips this field's assertion |
| `sdrBrightness` | real | 0.82.0, default 1.0 | `sdrbrightness` | Yes — `sdrBrightness` |
| `sdrSaturation` | real | 0.81.2, default 1.0 | `sdrSaturation` readback / `sdrsaturation` key | Yes |
| `mirrorOf` | string | `""` none (default) / another connected output name | `mirror` | Yes — `mirrorOf`; **and the x/y assertions in `matchesLayout` are skipped for a mirrored record** (position is compositor-chosen) |
Rules:
- `currentLayout()` reads all of these from the live `hyprctl -j monitors` readback (`vrrMode`
cannot be read back; it comes from the persisted record, defaulting to -1), so an apply that
changes one field carries the rest unchanged — that is the clobber fix.
- `pushLayout` emits every field with the omission rules above. If the `hl.monitor` Lua bridge
only serializes the current five keys, extend the bridge (find it under `config/dot/hypr/`);
keep its serialization literal-shaped for whatever parses it.
- Validation (`validRequestedLayout` / `DisplayLayout.validate`): `mirrorOf` must name a
*different* connected output that is not itself mirrored (no chains); the primary display may
not mirror; enum/range checks per the table. A mirrored record still carries mode/scale/
transform (Hyprland applies them) but is excluded from `bounds`/`normalize` geometry and from
overlap/position concerns in `DisplayLayout``canvasRects` stacks it on its target with a
badge flag instead.
- New API: `applyRecord(output, partial)` — merges a partial change object into the cloned
layout and funnels into `applyLayout()`. The existing `apply(output, mode, scale, transform)`
stays as a thin wrapper so nothing external breaks.
- `confirm()` persists the extended record; `isPersistedLayoutEntry` accepts the new fields as
**optional** (old stored blobs must remain valid); `forget()` unchanged.
- `monitors.lua`'s `display_entry` consumer emits the new fields when present and validates them;
invalid values fall back exactly like invalid geometry does today. Its hardcoded Kuycon
`bitdepth = 10` / `cm = "auto"` become the *defaults* the record inherits rather than values
the Settings path fights with.
- VRR override options deliberately exclude "fullscreen games" (that is the global policy's
value 3); the override menu is Follow / Off / Always on / Fullscreen only.
## Page layout (top to bottom)
`DisplaysPage.qml`, rebuilt. Page lede: "Changes apply to every display together and revert on
their own in 15 seconds unless you keep them."
1. **Confirmation banner** (pinned `header:` component — must stay outside the Flickable, the
displays-contract asserts this). Restyled: warn-tinted card with a **countdown ring** (SVG-like
Canvas or two arcs; updates once per second — no continuous animation), seconds numeral,
title "Keep these display settings?", body keeps the "If you cannot read this, just wait."
sentence, Revert now + Keep (Keep still disabled until `canConfirm`).
2. **Arrangement canvas** — always visible, single display included. Solo: the tile renders
centered with primary star, logical size caption, and the toolbar hint reads "One display
connected — plug in another to arrange"; drag disabled. Multi: existing drag/snap/nudge
behavior unchanged. Mirrored displays render stacked on their target with a "Mirrors <name>"
badge. Toolbar: Identify · Make primary (disabled for the primary/solo) · hint text. The
separate "Connected display" picker card is **replaced** by selector chips under the canvas
(one chip per display: thumbnail, name, primary star, connector + mode summary), shown only
with >1 display.
3. **Selected display panel** — one card. Header: display glyph, description, connector chip,
meta line ("4500 × 3000 at 60 Hz · 150% scale · sRGB 8-bit"), and the Custom setting pill +
Forget button when `isOverridden()`. Rows:
- **Resolution** — expandable mode list (existing `DisplayModePicker`, restyled: aspect-ratio
captions, "Current"/"Native" tags).
- **Refresh rate** — pill row (existing behavior), only when >1 rate at the current resolution.
- **Scale** — segmented control over `scalesForMode`, percent labels (100% / 150% / …).
- **Rotation** — segmented control with orientation glyphs, tooltips carry the full names.
- **Brightness** — DDC slider for this display (`Brightness.displays` matched by connector),
detail "Hardware brightness over DDC — the same dial as the monitor's buttons". Hidden when
the connector has no DDC entry; `Brightness.lastError` degrades the detail text exactly as
today. The quicksettings `BrightnessControl` is untouched.
- **Variable refresh rate** — dropdown: Follow gaming policy (shows the policy's current label
in the detail) / Off / Always on / Fullscreen only → `vrrMode`.
- **Use as** — dropdown: Extended display / Mirror of <each other display> → `mirrorOf`.
Disabled with one display, detail "Mirroring needs a second connected display".
4. **Color card** (per selected display). Subtitle notes color rides the same keep-or-revert
transaction. Right-aligned live readout "Now: sRGB · 8-bit (XRGB8888)" from readback.
- Profile tiles: Automatic / sRGB / Wide gamut / HDR (gradient swatches per the mock) →
`colorProfile`.
- Bit depth: 8-bit / 10-bit segmented, caption: "10-bit reduces gradient banding, but some
screen capture and recording tools can't read a 10-bit framebuffer."
- SDR brightness + SDR saturation sliders, visible only while the profile is `hdr`.
5. **"All displays" section**: Night Light card (existing rows; the temperature slider gets a
warm→cool gradient track) and Gaming card (Auto HDR, global VRR policy — detail notes displays
can override above — and Direct scanout) side by side in a 2-column grid; Workspaces card
(existing rows) below, ≥2 displays only. Error cards keep their current behavior.
Every per-display edit funnels through `applyRecord` and therefore the full transaction —
including brightness? **No**: brightness stays outside the transaction (hardware state, no
readback verification, bezel buttons change it behind our back) exactly as today.
## Search & docs
- `SettingsSearch.qml` hand-written entries (all `page: "displays"`): Resolution, Refresh rate,
Scale, Rotation, HDR, Color profile, Bit depth / 10-bit, SDR brightness, Mirror displays,
Variable refresh rate, Monitor brightness. Keep the existing three arrangement entries.
- README "Displays" ownership paragraph extends to name the new transaction fields and the
mirroring position carve-out. Keep the contract count line accurate if contracts are added.
- Settings docs / manual regeneration follows the generators; schema comments stay above entry
braces.
## Contracts (write, do NOT run — test window is closed)
- `display-transaction-contract`: extend fixtures for the new record fields; assert vrr omission
when `vrrMode === -1`, mirror x/y carve-out, format-unknown bitdepth skip, and that old-shape
persisted blobs still validate.
- `display-arrangement-contract`: **flip** the "canvas hidden for a single display" pin to
"canvas rendered for a single display, drag disabled"; add the mirror badge pin.
- `displays-contract` / `display-layout-contract`: update for validate/normalize mirror rules.
- Add every new/changed contract to the test backlog spec for the end-of-redesign sweep, along
with the still-pending unlocked re-run of `displays-contract` and `switcher-contract`.
## Agent ownership (parallel)
- **A — transaction**: `services/Displays.qml`, `services/DisplayLayout.js`,
`config/dot/hypr/monitors.lua` + the `hl.monitor` bridge.
- **B — UI**: `modules/settings/DisplaysPage.qml`, `DisplayArrangement.qml`,
`DisplayModePicker.qml`, any new components in `modules/settings/`.
- **C — periphery**: `services/SettingsSearch.qml`, `tests/quickshell/display*`, README section,
test-backlog spec, manual/docs regeneration.
B programs against the record/API table above; A must not change it without updating this spec.