# 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.8–2.0, default 1.0 | `sdrbrightness` | Yes — `sdrBrightness` | | `sdrSaturation` | real | 0.8–1.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 " 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 → `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.