148 lines
9.2 KiB
Markdown
148 lines
9.2 KiB
Markdown
# 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.
|