Bound the notification app list, and give Focus a real editor

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 14:25:26 -04:00
parent 07db1068f1
commit d5b6e62515
21 changed files with 1405 additions and 453 deletions
+5 -4
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.
164 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
165 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -149,11 +149,12 @@ Found on **Appearance**.
## focus
Found on **Notifications & Focus**.
Found on **Notifications & Focus Focus**.
| Setting | Default | What it does |
|---|---|---|
| **Focus modes**<br>`focusModes` | — | What quiets this machine, and what turns it on |
| **Focus session length**<br>`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5180. |
## gaming
@@ -258,7 +259,7 @@ Found on **Shell Tiling**.
## notifications
Found on **Notifications & Focus**.
Found on **Notifications & Focus Notifications**.
| Setting | Default | What it does |
|---|---|---|
@@ -266,6 +267,7 @@ Found on **Notifications & Focus**.
| **Critical notification duration**<br>`notificationTimeoutCriticalMs` | 0 ms | Zero keeps critical notification banners visible until dismissed. Range 060000. |
| **Notification history**<br>`notificationHistoryLimit` | 100 | Maximum notifications retained in the notification center. Range 10500. |
| **Visible banners**<br>`maxVisibleToasts` | 4 | Maximum notification banners shown at once. Range 18. |
| **Critical alerts break through**<br>`criticalBreaksThrough` | false | Show critical notifications as banners even while Do Not Disturb is on |
## pointer
@@ -408,7 +410,6 @@ Found on **Shell Workspaces**.
| Setting | Default | What it does |
|---|---|---|
| **Focus session length**<br>`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5180. |
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
@@ -358,3 +358,104 @@ trap, sound-cards' jq context rebind). displays-contract and switcher-contract
— the locked-session holdovers — both pass unlocked. Later phases append new
deferred contracts below as before; this line is the baseline they diverge
from.
## Phase 7 (Notifications & Focus) — append below
Spec: `2026-08-24-notifications-focus-redesign.md`. Notifications & Focus became
two tabs. Per-application rules grew from one bool to seven fields with a real
editor behind them, Do Not Disturb gained a second exception, and focus modes
got create/rename/delete/reorder plus trigger-kind editing on a page of their
own.
**Nothing in this wave was run.** Three agents were editing the tree
concurrently, and the one runtime harness here (`notification-app-rules-contract`)
boots a real Quickshell against a private D-Bus session. What *was* verified is
listed as static below: `bash -n` on every changed contract, and — for the two
contracts whose assertions are computed — the extracted blocks replayed in
`bun` and `python3` against the landed services, so the expected values in the
assertions are the values the shipped code actually produces.
### New contracts (0)
None. The redesign added surface to services and pages that already had
contracts, so the README count line stays at **169** and `setup/readme-contract`
needs nothing.
### Updated contracts (4)
| Contract | What it now pins | Verified |
|---|---|---|
| `quickshell/notification-app-rules-contract` | The rule shape, as two properties rather than a literal: every field past `enabled` is **optional** (an old `{enabled}`-only blob reads back with `sound`/`display`/`urgency`/`lastSeenMs`/`name`/`icon` at their defaults) and the field set is **closed** (unknown keys, the stale lock-screen pair, and out-of-vocabulary `display`/`urgency` values are all dropped; a non-numeric or negative `lastSeenMs` reads as 0). Plus: `forgetApp` deleting the key rather than writing an all-defaults rule; `rememberApplication` stamping `lastSeenMs`/`name`/`icon` from the clock; `effectiveUrgency` and all four of its consumers (`playBell`'s low check, `notificationTimeoutMs`'s critical duration, the breakthrough gate, `NotificationCard`'s critical edge); the per-app `sound` switch in `playBell`; `display: "history"` reaching history and unread but not the popup list or the bell; and the popup gate literal `!root.doNotDisturb \|\| FocusModes.allows(appId) \|\| breaksThrough` with `breaksThrough` being `Settings.criticalBreaksThrough` ANDed with effective-critical. The schema key and its `Settings` reader are pinned too. Page assertions now read the page **and** the components it delegates rows to (`NotificationAppRow.qml`), so pulling the expanded body into a component does not read as the feature being deleted. Runtime: four new fixtures — `historyOnly`, `forgetting`, `urgency`, `breakthrough` — and the two existing jq literals became field-by-field predicates, because `lastSeenMs` is a wall clock that cannot be written down. | **The whole static half was run** against the landed tree and prints PASS. The runtime half is **deferred**. |
| `quickshell/focus-modes-contract` | Every existing pin is untouched and still passes: conditions-not-alarms, the midnight-crossing / malformed / zero-length schedule arithmetic, single DND ownership, the gaming hook reporting rather than silencing, the retired `gamingSilenceNotifications` key staying out of the schema, and the exception list being both consulted and editable. New: `createMode`/`removeMode`/`renameMode`/`moveMode`/`setTriggerKind`/`seedTrigger`/`uniqueId`/`hasMode` by name; a `bun` replay of `seedTrigger` (all five kinds and their seeds, handed-in fields kept, unknown kinds refused with `null` rather than stored) and of `uniqueId` (slugging, suffixing past a taken id, and the `"mode"` fallback); the "order is priority" claim having to appear on the page; and the manual-mode semantics being *preserved* rather than invented — `automatic` still filters manual-only modes out and the fall-through still carries its note. The editable-list needle moved from `NotificationsPage.qml` to the Focus surface (`FocusPage.qml` + `Focus*.qml`), because the chips live in `FocusModeRow.qml`. | **Statically verified end to end**: every grep re-run against the landed files, the schedule arithmetic replayed in `python3`, and the new API block replayed in `bun` — all green. |
| `quickshell/settings-pages-contract` | `Focus` added to the component list (root type `SettingsPage`, no copied Flickable scaffold) and `focus` to the runtime page-routing sweep. The four Notifications sliders and the `zeroLabel: "Never"` rule are unchanged and still hit. | **Static half verified**: `FocusPage.qml`'s root type and the four slider regexes replayed in `python3`. The routing sweep is **deferred** — it starts an isolated shell. |
| `quickshell/settings-jump-contract` | GamingPage's "Open Focus" now has to go through `ShellState.openSettings("focus")`, and assigning `ShellState.settingsPage` by hand is banned there — the old code did exactly that, which skipped `SettingsRoutes.resolve()` and so skipped this contract's own guard entirely. `NotificationCard`'s jump stays `"notifications"` and is unchanged. | **Statically verified** against the landed `GamingPage.qml` and `NotificationCard.qml`. |
### Contracts deliberately left alone
- `quickshell/search-routing-contract` — needed no edit. It derives page files
from `SettingsShell`'s switch, so `focus` resolved to `FocusPage.qml` the
moment B landed the case and the Component. Replayed by hand against the
landed tree: **144 routed settings, 0 violations** with the focus group
moved.
- `quickshell/settings-window-contract` — enumerates no tabs. It routes
`displays` and the retired `desktop` id only.
- `quickshell/settings-ownership-contract` — its duplicate-row scan and its
`groupPages` parse were replayed against the edited `SettingsSearch.qml` and
still pass.
- `setup/readme-contract` — no contract file added or removed; `find` still
counts 169 and the README still claims 169.
### Docs updated in the same wave
- `services/SettingsSearch.qml``groupPages` `"focus"` moved from
`"notifications"` to `"focus"`, plus eight hand-written entries: Do Not
Disturb, Quiet hours, Critical alerts break through, Application
notification rules, Forget an app's notifications, Per-app notification
sound, and Banners or history (all → `notifications`), and Focus session
duration (→ `focus`). Checked against `settings-search-contract`'s fixed
query list: none of the new labels or details contains any of its queried
substrings, so no existing top result moves.
### Still open before the run
- **A live bug this wave removed, worth knowing about.**
`notification-app-rules-contract` used to `perl`-graft a temporary
`notificationAppRules` schema key into its copy of `PreferenceSchema.qml`,
from the era before that key shipped. The key ships now, and the graft's
anchor comment still exists — so the contract was defining it **twice** in
the copied schema on every run. The graft is gone, replaced by an assertion
that both `notificationAppRules` and `criticalBreaksThrough` are present.
- `NotificationAppRulesHarness.qml`'s fake notification now spells out
`urgency`, `expireTimeout` and `hints`. It previously left all three
undefined, which meant the timeout path and the urgency path were exercised
in their undefined branch rather than their ordinary one. If a fixture
behaves differently than expected on the first run, that change is the first
place to look.
- The `breakthrough` fixture drives `Settings.criticalBreaksThrough` by writing
the preference and reading the binding back in the same JS call. That is
synchronous through `DesktopPreferences.set` (it reassigns `values` and bumps
`revision` before returning), but it has not been observed. If the fixture
reports `through: 0`, suspect binding timing before suspecting the gate.
- **`focusDurationMinutes` is now editable on two pages**: a `SliderRow` on
`WorkspacesPage.qml` and segmented chips on `FocusPage.qml`. No contract
catches it — `settings-ownership-contract` only scans the five schema-bound
row types and the chips are a `SegmentedRow` reading `DesktopPreferences`
directly, and `search-routing-contract` only sees `setting:` rows — but it is
a real ownership violation by `modules/settings/README.md`'s own rule. Decide
before the run: either the Workspaces row goes, or the key's `group` moves to
`focus` and the mirror gets named in `settings-ownership-contract`.
- **A stale comment in `config/PreferenceSchema.qml`** above
`focusDurationMinutes` still says "The focus group routes to Notifications,
which is where focusModes renders." It routes to `focus` now. Left for the
schema's owner rather than edited across agent lines.
- Run order for the sweep: `focus-modes-contract` first (pure static, no
compositor and no shell), then `settings-jump-contract` and
`search-routing-contract` (also static), then
`notification-app-rules-contract` (private D-Bus, isolated shell), and
`settings-pages-contract` last — it starts an isolated Quickshell beside the
live one and its own cleanup is what protects the running session.
- Nothing here plays a sound on purpose, but every delivery fixture reaches
`playBell`, which shells out through `SoundFeedback.playCommand`. That was
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.