Give Input keycaps, a shortcut search, and the missing pointer basics

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 15:21:01 -04:00
parent b8f88a91f3
commit aba2d16ffa
25 changed files with 2346 additions and 191 deletions
@@ -459,3 +459,140 @@ needs nothing.
already true of the existing `exercise` fixture; the new fixtures add four
more chances for it. Keep the harness free of anything that turns the volume
up.
## Phase 8 (Input) — append below
Spec: `2026-08-24-input-redesign.md`. Keyboard, Mouse & Touchpad and Dictation
were rebuilt around keycaps, dropdowns and a searchable shortcuts browser, and
seven new compositor-backed preference keys landed with them.
**Nothing here was run against a live harness.** Three agents edited the tree
concurrently. What *was* verified is listed per contract below: `bash -n` on
every changed contract, the two source-only contracts run end to end, and the
three compositor-shape contracts replayed offline — `hyprctl descriptions` and
`hyprctl getoption` are read-only queries, so their answers were captured once
and the contract logic replayed against that snapshot with a stub on `PATH`,
never against the running compositor mid-edit.
### New contracts (0)
None. The redesign added rows, components and schema keys to surfaces that
already had contracts, so the README count line stays at **169** and
`setup/readme-contract` needs nothing. (`find` counts 169; the README says 169.)
### The seven new schema keys, per key
Each had to satisfy three contracts at once. Replayed against the landed
`PreferenceSchema.qml`, `hypr/input.lua` and a captured `hyprctl` snapshot:
| Key | Hyprland option | enum-hypr-map | schema-hypr-shape | hypr-prefs |
|---|---|---|---|---|
| `focusOnClose` | `input:focus_on_close` | **PASS** — enum, all three published values offered | **PASS**`readAs: "int"`, answers `int` | **PASS**`prefs.getInt("focusOnClose", 0)`, def 0 |
| `scrollMethod` | `input:scroll_method` | **PASS** — string enum, every offered word in the published list | **PASS**`readAs: "str"`, answers `str` | **PASS**`prefs.get("scrollMethod", "")`, def `""` |
| `scrollButton` | `input:scroll_button` | n/a — `type: "int"`, not an enum | **PASS**`readAs: "int"`, answers `int` | **PASS**`prefs.get("scrollButton", 0)`, def 0 |
| `cursorHideWhileTyping` | `cursor:hide_on_key_press` | n/a — bool | **PASS**`readAs: "bool"`, answers `bool` | **PASS** — def false |
| `cursorWarpOnWorkspaceChange` | `cursor:warp_on_change_workspace` | n/a — bool over an int option, so the enum rule does not reach it | **PASS**`readAs: "int"`, answers `int` | **PASS**`prefs.getInt(..., 0)` vs schema `false`; the contract's own true/false→1/0 normalization is what makes those agree |
| `touchpadClickfinger` | `input:touchpad:clickfinger_behavior` | n/a — bool | **PASS**`readAs: "bool"` | **PASS** — def false |
| `touchpadTapAndDrag` | `input:touchpad:tap-and-drag` | n/a — bool | **PASS**`readAs: "bool"` | **PASS** — def true |
Two of those are decisions, not just passes, and both are recorded in the
schema itself:
- `focusOnClose` was specced as a two-way choice. The compositor publishes
three (`{"mru":2},{"cursor":1},{"next":0}`) and **0 is what this desktop runs
today**, so a two-option dropdown would have hidden the shipped default from
its own control. enum-hypr-map-contract fails an enum that drops a published
value, and would have caught it — verified by deleting value 0 from a copy of
the schema and watching it fail with exactly that message.
- `cursorWarpOnWorkspaceChange` is a switch over an option with three states.
`force` (2) is deliberately unreachable from Settings. enum-hypr-map governs
enums only, so nothing fails — which is the point of writing it down here.
### Updated contracts (6)
| Contract | What it now pins | Verified |
|---|---|---|
| `quickshell/enum-hypr-map-contract` | **String-valued enums are now checked at all.** The parser only ever collected numeric `value:`s, so `accelProfile`, `masterOrientation`, `masterNewStatus` and `windowLayout` were silently skipped and `scrollMethod` would have been too. String options carry no `map`; Hyprland states their accepted words inside the description (`[2fg/edge/on_button_down/no_scroll]`), so those are parsed and checked **one way only**: an offered value the compositor does not name fails; a named value Settings does not offer does not, because that is a product decision (`accel_profile`'s `custom` needs a `scroll_points` curve and is a stated non-goal). Empty string is always allowed — it is how a schema entry says "leave the compositor's default", which is what `[[EMPTY]]` reads back as. Options with no bracket list print a line saying so and are skipped rather than failing. The numeric rules are untouched. | **Replayed offline** against a captured `hyprctl descriptions` (353 options) and the landed schema: PASS, 11 mapped enums (was 9). Both new failure directions exercised on a scratch copy — dropping `focus_on_close`'s value 0 fails, offering `"two_finger"` for `scroll_method` fails. |
| `quickshell/xkb-presets-contract` | Rewritten for the Advanced disclosure. The raw `keyboardOptions`, `keyboardVariant` **and `keyboardLayout`** fields must still be editable on the Keyboard page — matched as blocks, not one-liners, so nesting them inside an expander is fine — and **collapsed-but-present passes while absent fails**: the section must be named ("Advanced") and some `onClicked`/`onTriggered`/`onToggled` handler must actually open it, and no raw field may be pinned `visible: false`. XKB *values* stay pinned exactly (`caps:escape_shifted_capslock`, `caps:ctrl_modifier`, `compose:ralt`, `grp:win_space_toggle`) because moving one changes somebody's keyboard; row *labels* are now matched loosely and case-insensitively, because "Compose key" → "Compose" is a wording decision. The category-preservation rule, both helper signatures, and the three `input.lua`/schema default needles are unchanged. | **Run end to end** against the landed tree — it is source-only and touches neither compositor nor shell. PASS. Nested-block matching and the "no handler ⇒ fail" direction both exercised. |
| `quickshell/keybinds-contract` | A static presentation half ahead of the existing live count check, which a wall of 130 rows and a searchable browser pass identically. Page and `ShortcutRow.qml` are read as one source, so moving a control between them is not a failure: `Keybinds.grouped()` is what supplies the group order, `KeycapChord` is what draws chords, the filter exists and is case-insensitive and matches on `description`, a filtered list says how many of how many it is `showing`, the header count comes from `Keybinds.binds.length`, and the note explaining why there is no GNOME keyboard handoff survives. Count-match, description-completeness and chord-rendering rules are untouched. | **Static half replayed** against the landed `ShortcutsPage.qml` + `ShortcutRow.qml`: all needles hit. The compositor half is **deferred** — it boots a Quickshell harness. |
| `quickshell/keybind-rebind-contract` | UI needles added to the static half (the one that already runs under `PANAMA_KEYBINDS_STATIC_ONLY=1`). Page + row read as one source: `ShortcutCapture` is still what reads key presses (a page that grew its own handler would capture SUPER as a bind of its own), `boundTo` is called **before** `rebind` on the source line order, Change/Reset/`resetBind`/`resetAll`/`isOverridden` all still exist, binds are identified by `luaChord`, and no `rebind`/`resetBind` call is keyed by `description` — the regression that once moved every bind sharing one and cost the XF86Calculator key. The restore-all row and its live differs-count are pinned on the page. Engine needles and the whole live half are untouched. | **`PANAMA_KEYBINDS_STATIC_ONLY=1` run against the landed tree: PASS (static).** All 20 needles individually replayed. Live half **deferred**. |
| `quickshell/settings-pages-contract` | `Shortcuts` and `Mouse` added to the root-type/no-copied-Flickable sweep (neither page was ever in it), `mouse` added to the runtime routing sweep, and a hand-written check that all seven new keys render on `MousePage.qml` — by `setting: "key"` *or* by `commitPreference("key"`. That second spelling is why it is hand-written: dropdowns now render through `OptionPickerRow`, which takes label and options from `PreferenceSchema.spec()` and commits by name, and **has no `setting:` property at all**. Existing Home/Bar/Notifications/ScreenIntelligence pins unchanged. | **Static half run: PASS.** Routing sweep **deferred** — it starts an isolated Quickshell beside the live one. |
| `setup/dictation-contract` | **Every "where text lands" pin is unchanged and none of them conflicts with the on-page test.** They live on `panama-dictate` and `keybinds.lua``is_speech` rejecting `[BLANK_AUDIO]`, the guard actually being called before typing, the newline collapse, `wtype` tried before `wl-copy`, one press bind and one release bind — and nothing in them constrains which window has focus. The Try-it field sends the same `start`/`stop` the hotkey sends and merely holds keyboard focus while `wtype` types. What is new is two needles for the risk the test flow *did* introduce: the page must not spell out `scripts/panama-dictate` (the service publishes that path once, and a second copy would go stale silently, since the page's status readout comes from the service and would still be right), and a page that runs a `Process` must go through `Dictation.helper`. | **Run end to end** — it is greps plus a `python3` import of the helper, no compositor and no shell. PASS, including both new needles against the landed `DictationPage.qml`. |
### Verified against the new tree, no edit needed
- `quickshell/schema-hypr-shape-contract` — derives everything from
`option: "...", readAs: "..."` pairs in the schema, so the seven new keys
entered it the moment they landed. All seven extract cleanly and all seven
`readAs` values match what `hyprctl -j getoption` answers with. Two were easy
to get wrong and are worth naming: `cursor:warp_on_change_workspace` answers
`int` despite being a switch in the UI, and `input:scroll_method` answers
`str` despite the neighbouring `scroll_button` answering `int`.
- `tests/hypr/hypr-prefs-contract` — pure static, and **run**: ok, 77
compositor-owned keys read at config time, up from 70. All seven new keys
have a `prefs.get()`/`prefs.getInt()` in `config/dot/hypr/input.lua` with a
fallback equal to the schema default.
- `quickshell/gnome-handoff-contract` — needle-free by construction (it derives
both sides). **Run**: ok, 14 handoffs checked against 39 pages. The Keyboard
page still has no GNOME handoff and still explains why.
- `quickshell/schema-hypr-shape-contract`, `tests/hypr/hypr-prefs-contract` and
`quickshell/gnome-handoff-contract` are the three above. `setup/readme-contract`
is a fourth: no contract file was added or removed, `find` still counts 169,
and the README still claims 169.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — three hand-written entries: **Rebind a
shortcut** (→ `shortcuts`), **Pointer test area** and **Connected input
devices** (→ `mouse`). "Key repeat" and "Scroll method" arrive automatically
from the schema, as the spec expected. Checked against
`settings-search-contract`'s fixed query list: of its 27 pinned queries only
`pointer` matches any new entry, and "Pointer test area" sorts *after*
"Pointer focus", "Pointer size" and "Pointer speed" in the same prefix rank,
so no pinned top result moves. Both new pages are leaves in
`SettingsRoutes`, so the "routes to a page anyone can land on" sweep holds.
- No settings docs or launcher commands were regenerated here — that is the
orchestrator's step after the schema settled.
### Still open before the run
- **`settings-ownership-contract` and `search-routing-contract` are now blind
to dropdown rows.** Both scan for `setting: "…"` inside a fixed list of row
types; `OptionPickerRow` is in neither list and carries no `setting:`
property. `accelProfile`, `followMouse`, `focusOnClose` and `scrollMethod`
are all invisible to them on the rebuilt `MousePage.qml`. Nothing fails
today — none of those keys is a duplicate — but a duplicate introduced
through a dropdown would not be caught. `settings-pages-contract` now pins
the seven new keys directly as a stopgap; the real fix is teaching both
scans the `PreferenceSchema.spec()` / `commitPreference()` spelling. Owner:
whoever holds those two contracts next.
- **`xkb-presets-contract` now requires a raw `keyboardLayout` field**, on the
reading that the layout dropdown's "Custom…" has to reveal somewhere the
code can actually be typed. It passes against the landed page. If the layout
editor is ever folded into the dropdown itself, that needle is the one to
revisit — the intent is "the raw code stays typeable", not "it is a
TextEntryRow".
- **`keybinds-contract` pins the literal word `showing`** in the filtered-count
line, because the spec names that wording ("showing N of M"). It is the one
prose needle in the new static half; everything else keys on structure.
- **Dictation's Try-it field and the clipboard fallback** — handled, but worth
knowing. `panama-dictate` falls back to `wl-copy` when `wtype` is missing, by
design, and on the Try-it row that means the words land on the clipboard
rather than in the field the page just focused. The page says so: a "Typing —
Missing" row appears when `Dictation.typingAvailable` is false. Nothing to
fix; worth a look during the run if a machine without `wtype` is around, since
that branch has never been seen.
- Run order for this phase: the two source-only contracts first
(`xkb-presets-contract`, `gnome-handoff-contract`), then the static halves
(`hypr-prefs-contract`, `PANAMA_KEYBINDS_STATIC_ONLY=1 keybind-rebind-contract`,
`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract`), then the two
compositor-query contracts (`enum-hypr-map-contract`,
`schema-hypr-shape-contract` — read-only, but they want the real compositor),
then the harness contracts (`keybinds-contract`, `keybind-rebind-contract`
in full, `settings-search-contract`), and `settings-pages-contract` last, as
before: it starts an isolated Quickshell beside the live one and its own
cleanup is what protects the running session.
- `keybind-rebind-contract`'s live half rebinds Terminal to `SUPER + SHIFT +
F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That
was true before this phase and is unchanged, but it is the one contract in
this wave that writes to the running keymap, so it wants a quiet moment.
@@ -0,0 +1,122 @@
# Input redesign — keycaps, search, and honest state
Approved mock: `home-mocks/input.html` (scratchpad, :8642). Spec wins over mock on conflict.
## Goals
1. **Shortcuts browser**: the 130-row wall becomes one searchable card — keycap chips, group
headers with counts, hover-revealed Change/Reset, inline capture. The rebinding engine
(`Keybinds.rebind`, conflicts refused, `keybindOverrides`) already exists and is untouched.
2. **Typing card modernized**: dropdowns instead of ChoiceGrid tile walls; raw XKB string and
variant into a collapsed Advanced section (still discoverable — a contract requires the raw
string not be hidden *away*, collapsed-but-present satisfies it; C verifies the needle).
3. **Mouse & Touchpad**: dropdowns over wide segmented rows, four new option groups (below),
a live try-it area, and a phantom-filtered device list.
4. **Dictation status-first**: ready hero, hotkeys as keycaps (looked up live from Keybinds so a
rebind shows truthfully; literal fallback), guided setup steps with a real progress bar, and
an on-page test field that uses the existing typing pipeline.
Non-goals: per-device settings (phantom-heavy device list, zero plumbing — deliberately
skipped), editing keybinds.lua actions (chords only, as today), touch/tablet options
(no hardware), accel `custom` curves.
## New schema keys (group / hypr option — agent A verifies each option's exact name, type, and
value map against `hyprctl descriptions` before writing the entry; enum-hypr-map-contract and
schema-hypr-shape-contract must hold; every key also gets its `prefs.get()` read-back in
`config/dot/hypr/input.lua` with matching defaults — hypr-prefs-contract):
| Key | Group | Hyprland option | UI |
|---|---|---|---|
| `focusOnClose` | pointer | `input:focus_on_close` | dropdown: "Most recently used" / "Under the pointer" |
| `cursorHideWhileTyping` | pointer | `cursor:hide_on_key_press` | toggle "Hide pointer while typing" |
| `cursorWarpOnWorkspaceChange` | pointer | `cursor:warp_on_change_workspace` | toggle "Jump pointer to the focused display" |
| `scrollMethod` | pointer | `input:scroll_method` | dropdown (offer only map-published values; include "On a held button" only if the map allows) |
| `scrollButton` | pointer | `input:scroll_button` | int row, visible only when scrollMethod is the button one |
| `touchpadClickfinger` | touchpad | `input:touchpad:clickfinger_behavior` | toggle "Two-finger right-click" |
| `touchpadTapAndDrag` | touchpad | `input:touchpad:tap-and-drag` | toggle "Tap and drag" |
Defaults = today's effective Hyprland/input.lua values so shipping changes nothing. If an
option's published map/type makes a row above impossible as specced (e.g. scroll_method values),
implement what the map allows and flag the difference loudly.
## Service work (A)
- `InputDevices.qml`: add `realMice` / `realKeyboards` — name-filtered lists (exclude
substrings: `consumer-control`, `virtual`, `video-bus`, `power-button`, `webcam`,
`audio`, `uinput`, plus dedupe transceiver siblings by prefix), each entry `{ name, pretty }`
(pretty = title-cased, dashes to spaces). Keep the existing flat lists and `hasTouchpad`
untouched (contract-pinned behavior).
- Expose `mainKeyboardLayout` (from the main keyboard's live layout string) for the devices card.
## UI (B)
**ShortcutsPage.qml** (title stays "Keyboard"):
1. *Typing* card: Layout dropdown (curated common layouts: English (US), English (UK), German,
French, Spanish, Nordic…, mapping to `keyboardLayout` codes; a stored code outside the list
renders as the raw code and the dropdown offers "Custom…" which reveals Advanced), Caps Lock
dropdown, Compose dropdown, Layout-switching dropdown (all four presets keep writing
`keyboardOptions` through the existing page-local XKB helpers — xkb-presets-contract),
combined Key repeat row (delay + rate sliders), Num Lock toggle, then **Advanced** expander:
raw `keyboardOptions` mono field + `keyboardVariant`.
2. *Shortcuts* card: header "130 bound · N changed" (live counts), subtitle "Click Change and
press the new keys. A shortcut another action holds is refused, never stolen.", filter field
(matches description + group, case-insensitive), grouped rows in `Keybinds.groupOrder` with
"showing N of M" when filtered; each row: description, CHANGED badge when overridden,
hover-revealed Change/Reset (Reset only on overridden), keycap chord. Capture swaps the
chord area for the existing `ShortcutCapture` inline. "Restore every shipped shortcut" row
stays, with the live differs-count detail. Keep the no-GNOME-handoff comment.
3. New component **KeycapChord.qml**: parses a display chord ("SUPER + SHIFT + Q") into keycap
chips — mono font, tabular figures, modifier caps tinted accent, "+" separators muted.
Reused by DictationPage.
**MousePage.qml**: Mouse card (speed slow/fast, Acceleration dropdown, Scroll speed, Natural
scrolling, Left-handed, Middle-click paste, Scroll method dropdown + conditional Scroll button
row) · Touchpad card (existing rows + the two new toggles; visible on `hasTouchpad`) ·
Pointer behavior card (Focus dropdown — the 4 followMouse values, focusOnClose dropdown,
hide-while-typing, hide-after slider with "Never" zero, warp toggle, Pointer size) ·
**Try it** card (new `InputTestArea.qml`: a scribble Canvas — repaints only on pointer motion,
cleared by a corner button — and a scrollable text strip; purely local, no compositor writes) ·
**Connected devices** card from `InputDevices.realKeyboards/realMice` (+ touchpad when
present), with the subtitle noting phantoms are filtered. Gestures rows fold into the Touchpad
card (swipe distance + invert) — the separate Gestures card goes.
**DictationPage.qml**: ready hero (state tile ✓ / … / ✗, title, model+size line) · hotkey rows
with KeycapChord, chords looked up from `Keybinds.binds` by matching the dictate descriptions
(fallback literals if not found) · **Try it** row: a read-only-styled TextField + "Test
dictation" button that focuses the field and drives `panama-dictate start`/`stop` through the
existing Dictation service — dictated text lands in the field via the normal wtype pipeline, no
new script plumbing; detail explains it types here instead of your document · Microphone
ActionRow → Sound (unchanged) · setup state replaces the hero with numbered steps (container
image / speech model with progress bar from `downloadFraction` / first transcription), driven
by the existing `phase` fields; errors keep their row.
## Search & docs (C)
Extra entries: "Rebind a shortcut" → shortcuts; "Key repeat" auto via schema; "Pointer test
area" → mouse; "Scroll method" auto; "Connected input devices" → mouse. Existing entries stay.
Docs + launcher commands regenerate after schema lands (orchestrator).
## Contracts (C — write, never run)
- `xkb-presets-contract`: verify/adjust needles for the Advanced placement (raw string must
remain present in the page source).
- `keybinds-contract` / `keybind-rebind-contract`: UI needles (Change/Reset/ShortcutCapture
usage) reconciled with the rebuilt page; count-match and conflict rules unchanged.
- `enum-hypr-map-contract` / `schema-hypr-shape-contract` / `hypr-prefs-contract`: the seven
new keys must satisfy all three (C statically replays where possible).
- `settings-pages-contract`, `gnome-handoff-contract` needles re-verified.
- `dictation-contract`: confirm the test-field flow doesn't violate the "where text lands" pins
(it uses the normal pipeline; the page merely owns focus). Flag, don't force, if it conflicts.
- Backlog spec: Phase 8 section.
## Agent ownership (parallel)
- **A**: `config/PreferenceSchema.qml` (7 new keys), `config/dot/hypr/input.lua`,
`services/InputDevices.qml`.
- **B**: `modules/settings/ShortcutsPage.qml`, `MousePage.qml`, `DictationPage.qml`, new
components (`KeycapChord.qml`, `InputTestArea.qml`, others as needed) + `modules/settings/qmldir`.
- **C**: `services/SettingsSearch.qml`, the contracts above, backlog spec, README count line
only if the count changes.
B programs against the schema keys and InputDevices API above; A must not change them without
updating this spec.
@@ -0,0 +1,147 @@
# Notifications & Focus redesign — two tabs, nothing unbounded
Approved mock: `home-mocks/notifications.html` (scratchpad, :8642). This spec is the
implementation contract; where mock and spec disagree, the spec wins.
## Goals
1. **Bound the app list.** Recent senders + customized apps render up top; everything else sits
behind a collapsed, searchable "All apps" expander. Rows expand in place to their controls.
2. **Real per-app rules**: sound on/off, banners-vs-history, urgency override, forget.
3. **A real focus-mode editor**: create/rename/delete/reorder (order IS priority), trigger-kind
editing for all five kinds, schedules with day pills, chip-based interrupt lists.
4. **Two tabs**: Notifications | Focus, as category tabs like Shell.
Non-goals: lock-screen privacy (hyprlock cannot render notifications; contract-banned),
per-app counters (no data), time-based history retention, merging duplicate app identities
(show the raw id honestly instead).
## Rule shape (pinned — service and UI program against this)
`normalizedAppRule` in `Notifs.qml` grows from `{ enabled }` to:
| Field | Type | Default | Meaning |
|---|---|---|---|
| `enabled` | bool | true | Off = rejected before tracking/history/unread/toast (unchanged) |
| `sound` | bool | true | false = `playBell` skips this app |
| `display` | string | `"banners"` | `"history"` = file in history + unread, no popup, no bell |
| `urgency` | string | `"auto"` | `"low"` / `"critical"` override what the app claims |
| `lastSeenMs` | int | 0 | stamped in `rememberApplication` on every notification |
| `name` | string | "" | display name cached at remember time (resolution stays live-first) |
| `icon` | string | "" | icon cached at remember time (DesktopEntries lookup, else `appIcon`) |
Unknown/stale fields (incl. the old lock-screen pair) keep being dropped on read. Old
`{enabled}`-only blobs stay valid — every new field is optional with the defaults above.
New API: `forgetApp(appId)` deletes the rule key outright.
`effectiveUrgency(notification)` — the app's `urgency` override applied over
`notification.urgency`; consumed by `playBell` (low = silent), the popup timeout choice
(critical duration), the DND breakthrough gate, and `NotificationCard`'s critical edge.
**Critical breakthrough**: new schema key `criticalBreaksThrough` (bool, def **false**, group
`notifications`, label "Critical alerts break through"). Popup gate becomes: show when
`!doNotDisturb || FocusModes.allows(appId) || (Settings.criticalBreaksThrough &&
effectiveUrgency(n) === critical)`.
## FocusModes API additions (pinned)
- `createMode(name)` → new mode `{ id: unique slug, name, enabled: true, triggers: [{kind:
"manual"}], silence: true, keepAwake: false, allow: [] }`, appended (lowest priority).
- `removeMode(id)`, `renameMode(id, name)` (non-empty, trimmed).
- `moveMode(id, delta)` — reorder; order is priority and the UI says so.
- `setTriggerKind(id, kind, fields)` — replaces the mode's `triggers` with one trigger of the
new kind. Kind-specific seeds: schedule → `{ start: "22:00", end: "07:00", days: [0..6] }`;
workspace → `{ id: 1 }`; fullscreen/game/manual → no fields. (Shipped modes each carry one
trigger; a hand-edited multi-trigger mode collapses to one on first kind change — the editor
edits `triggers[0]` and that is documented in a comment.)
- **Manual-mode semantics**: investigate how a manual-trigger mode activates today and PRESERVE
it exactly; if manual modes currently have no activation path besides `enabled`, the header
toggle keeps meaning `enabled` and the editor's "Turns on: Manually" detail explains that a
manual mode quiets things whenever it is switched on. Do not invent new activation machinery.
- Existing `setEnabled`/`update`/`reschedule`-style schedule + day editing semantics stay;
`withinWindow`, single-DND-ownership, and the gaming report-don't-silence rule are
contract-pinned and untouched.
## Routing (pinned)
`SettingsRoutes` category `notifications` gains tabs:
`[{ page: "notifications", label: "Notifications" }, { page: "focus", label: "Focus" }]`.
New leaf `focus` → new `FocusPage.qml`; `SettingsShell` case + Component. `groupPages`
`"focus"` moves `"notifications"` → `"focus"` (the focusModes entry renders there now).
`GamingPage`'s "Open Focus" action retargets `openSettings("focus")`.
`NotificationCard`'s "Notification settings" jump stays `"notifications"`.
## Notifications tab (NotificationsPage.qml rebuilt)
Lede unchanged. Cards:
1. **Quiet** — Do Not Disturb toggle; "Critical alerts break through" ToggleRow
(`criticalBreaksThrough`); "Quiet hours" ActionRow whose detail states the Sleep mode's
live schedule (or "not scheduled" when Sleep lacks/disabled) and whose button opens the
Focus tab (`openSettings("focus")`).
2. **Banners & history** — the four existing schema sliders (names contract-pinned; critical
zero renders "Never"); history count + Clear folded into the history row.
3. **Applications** — subtitle per mock. Sections:
- *Recent*: apps with `lastSeenMs` within 7 days, newest first.
- *Customized*: any app with a non-default field (and not already in Recent).
- *All apps (N)*: collapsed expander with an inline search field; alphabetical.
Rows: cached icon (fallback letter tile), name, subtitle (relative last-seen when known ·
state summary like "sound off"/"History only", else the raw appId), enabled toggle, chevron.
Expanded body: Play sound toggle · "Show as" dropdown (Banners & history / History only) ·
"Urgency" dropdown (App decides / Treat as low / Treat as critical) · "Forget this app"
ActionRow (detail: "Remove its rule; it returns on its next notification").
4. **Active-mode banner** at top (ok-tinted) when a focus mode is active: "<name> is quieting
notifications · because <reason> · <allow summary>", button "Open Focus".
## Focus tab (FocusPage.qml, new)
Lede: "Modes quiet this machine on their own terms — first matching mode wins, and the order
below is the priority."
1. **Focus modes** card — accordion (one open at a time): drag grip (reorder = priority; also
keyboard up/down on the grip), mode glyph, name, summary line ("Turns on <trigger summary> ·
silences everything except N apps" / "On now — <reason>"), enabled toggle, chevron.
Expanded: "Turns on" dropdown (five kinds) + kind fields (schedule start/end `TimeOfDayRow`-
style or validated HH:MM inputs + seven day pills; workspace id picker); "Silence
notifications" toggle; "May interrupt" chip row (chips with ×, "+ Add app" opens a searchable
picker over known apps — reuse the rules list's app universe); "Keep the screen awake"
toggle; Rename + Delete mode buttons. "+ New focus mode" dashed row at the bottom.
2. **Focus sessions** card — default duration segmented chips (25/45/60/90 →
`focusDurationMinutes`), Caffeine toggle, session status row + Start focus/Show controls
(existing behaviors move over unchanged).
Reuse existing row widgets and the Displays/Sound phase components (OptionPickerRow, etc.)
before inventing new ones. No continuously repainting animations. All new components get
qmldir lines in the same wave as first reference.
## Search & docs
Hand-written entries (page per target): Do Not Disturb, Quiet hours, Critical alerts break
through, Application notification rules, Forget an app's notifications, Per-app notification
sound → `notifications`; Focus modes detail already schema-indexed (now routes to `focus`),
plus Focus session duration → `focus` if not covered by the workspaces group move. Docs and
launcher commands regenerate after the schema lands (orchestrator's audit pass).
## Contracts (write, do NOT run — cite in the backlog for the next sweep)
- `notification-app-rules-contract`: extend the pinned rule shape to the table above (defaults,
optional back-compat, stale-field dropping incl. lock-screen pair), pin `forgetApp`, the
display="history" no-popup-no-bell path, sound=false no-bell, `effectiveUrgency` consumers,
and the breakthrough gate literal.
- `focus-modes-contract`: pin the new CRUD/reorder/trigger APIs, keep every existing pin
(conditions-not-alarms, DND ownership, gaming reports, exception list consulted + editable —
the editable needle moves to FocusPage).
- `settings-pages-contract` (page id list + component list), `settings-jump-contract`
(GamingPage → focus), `search-routing-contract` expectations, `settings-window-contract` if
it enumerates tabs.
- Backlog spec gains a Phase 7 section listing all of it.
## Agent ownership (parallel)
- **A — services**: `services/Notifs.qml`, `services/FocusModes.qml`,
`config/PreferenceSchema.qml` (one new key; comments above braces).
- **B — UI**: `modules/settings/NotificationsPage.qml`, new `modules/settings/FocusPage.qml` +
new components + `modules/settings/qmldir`, `services/SettingsRoutes.qml`,
`modules/settings/SettingsShell.qml`, `modules/notifications/NotificationCard.qml`
(effectiveUrgency), `modules/settings/GamingPage.qml` (Open Focus target).
- **C — periphery**: `services/SettingsSearch.qml`, the contracts above + harness fixtures,
test-backlog spec, README count line only if the contract count changes.
B programs against the pinned shapes; A must not change them without updating this spec.