202 lines
13 KiB
Markdown
202 lines
13 KiB
Markdown
# Tier 2 — the identity block
|
||
|
||
Approved from mock `tier2-identity.html` (2026-08-25). Custom shortcuts, per-app window rules,
|
||
assignable four-finger gestures — all fed by one named-action system — plus network truths
|
||
(static IP both stacks, metered, saved networks, hidden SSID), color filters, the DND tile,
|
||
tokenized search, the known-hosts cap, and the GNOME umbrella button's removal.
|
||
|
||
## The one safety property
|
||
|
||
`settings.json` stays non-executable. No stored value is ever a command. A custom shortcut, a
|
||
gesture assignment, and a window rule are all **data**: an enum kind, a validated target id, and
|
||
booleans. The Lua resolves data → action at config time through whitelist tables only; an unknown
|
||
kind or an invalid target means the bind/gesture/rule is silently not emitted (the `prefs.get`
|
||
philosophy: never raise, never guess).
|
||
|
||
## Named actions (shared vocabulary)
|
||
|
||
Stored shape, used by `customBinds` and the four gesture keys:
|
||
|
||
```json
|
||
{ "kind": "app" | "shell" | "window", "target": "<id>", "label": "<display text>" }
|
||
```
|
||
|
||
- `app` — target is an application/desktop id matching `^[A-Za-z0-9@._-]{1,128}$`, launched via
|
||
the existing launch-or-focus path. The id is an *argument*, never interpolated into a shell string.
|
||
- `shell` — target is a key of a fixed Lua table mapping to existing shell IPC verbs
|
||
(`dnd-toggle`, `screenshot-area`, `color-picker`, `clipboard`, `overview`, `lock`, … — the
|
||
final list is exactly the verbs `shell.qml` already exposes; verify there, don't invent).
|
||
- `window` — target is a key of a fixed dispatcher table (`float-toggle`, `fullscreen`, `pin`,
|
||
`workspace:1`..`workspace:10`; `workspace:N` validated 1–10).
|
||
|
||
Resolution lives in a new `config/dot/hypr/actions.lua` required by both `keybinds.lua` and
|
||
`input.lua`. QML renders labels from the stored `label` and re-derives validity with the same
|
||
rules (a service-side `describeAction()` in Keybinds.qml is the single QML authority).
|
||
|
||
## Schema keys (already added by the orchestrator — do not re-add)
|
||
|
||
| key | type | def | group | internal | notes |
|
||
|---|---|---|---|---|---|
|
||
| `customBinds` | json | `[]` | input | yes | array of `{chord, kind, target, label}` |
|
||
| `windowRules` | json | `[]` | multitasking | yes | array, shape below |
|
||
| `gestureFourUp/Down/Left/Right` | json | `({})` | touchpad | yes | `{}` = unassigned, else a named action |
|
||
| `colorFilter` | string enum `none/grayscale/protanopia/deuteranopia/tritanopia` | `"none"` | accessibility | no | **no `hypr:` block** — stored value is the enum, not the shader path |
|
||
|
||
## 1. Custom shortcuts
|
||
|
||
- `keybinds.lua`: after the shipped binds, a `category("Custom")` loop over
|
||
`prefs.get("customBinds", {})`. Each entry: `valid_chord(chord)` must pass, `label` non-empty
|
||
(it becomes the bind description — `keybinds-contract` fails builds with description-less
|
||
binds), action resolved via `actions.lua` or the entry is skipped. Custom chords must also not
|
||
collide with an already-emitted chord (skip + keep the shipped one; QML prevents this upstream).
|
||
- `keybindOverrides` stays chord-only and keys by *shipped* chord; custom binds are edited in
|
||
place in `customBinds` (rebind rewrites the entry's `chord`), so the two mechanisms never meet
|
||
in `shippedChordFor`/`overrideOccupantFor`. Keybinds.qml must exclude custom chords from the
|
||
override map's domain.
|
||
- Keybinds.qml grows: `customBinds` reader, `addCustomBind(chord, kind, target, label)`,
|
||
`rebindCustomBind`, `removeCustomBind` (all conflict-checked via `boundTo`, all ending in
|
||
`applyReload()`), `describeAction(entry)`. `groupOrder` gains `Custom` **first**.
|
||
- ShortcutsPage: "+ Add shortcut" in the card header → the two-step flow from the mock
|
||
(ShortcutCapture for the chord, then kind picker + target picker; apps from the applications
|
||
catalog, shell verbs and window verbs from fixed lists mirroring `actions.lua`). Custom rows
|
||
render in the pinned-first "Custom" group with rebind + remove (remove via ConfirmAction,
|
||
`actionId: "custom-bind-remove:" + chord`).
|
||
- Contracts: `tests/hypr/keybind-categories-contract` — add `Custom` to `known` (keep the
|
||
Other-must-be-empty rule; Custom may be non-empty). `tests/quickshell/keybind-rebind-contract`
|
||
— keep the overrides pin verbatim; add a customBinds pin: every entry's `kind` ∈ the enum,
|
||
`target` matches the safe regex, `chord` < 64 chars, and a static check that `actions.lua`
|
||
builds exec strings only from its own whitelist tables (no `entry.target` concatenated into a
|
||
command except as a quoted argv element).
|
||
|
||
## 2. Window rules
|
||
|
||
Stored shape:
|
||
|
||
```json
|
||
{ "class": "<window class, literal>", "label": "<app display name>",
|
||
"float": bool, "center": bool, "size": [w, h] | null, "workspace": int | null,
|
||
"noAnim": bool, "game": bool, "noDim": bool, "pin": bool }
|
||
```
|
||
|
||
- `rules.lua`: after the shipped rules, a loop over `prefs.get("windowRules", {})` emitting one
|
||
`hl.window_rule` per entry, **class regex-escaped** (literal match). Validation: class
|
||
non-empty ≤ 128 chars, size within 50..10000, workspace 1..10; invalid entry skipped whole.
|
||
Shipped rules always emit first (user rules are anonymous → evaluated after named ones anyway;
|
||
do not name user rules).
|
||
- New `services/WindowRules.qml` singleton modeled on `Workspaces.qml`: owns the pref array,
|
||
`addRule/updateRule/removeRule`, the 120 ms write-settle + `hyprctl reload` + 350 ms refresh,
|
||
and an honest `applied` readback (`hyprctl -j` — use whatever rules listing the running
|
||
Hyprland exposes; if none exists, readback = "reload exit status + re-parse of prefs" and the
|
||
card says applied-on-reload rather than pretending).
|
||
- TilingPage: "App rules" card above the shipped Tiling card, per the mock — list rows (label,
|
||
plain-language behavior summary, mono rule line), edit + remove (ConfirmAction,
|
||
`actionId: "window-rule-remove:" + class`), add flow: app picker (running toplevels via
|
||
`Hyprland.toplevels` first, then the applications catalog) + behavior ticks.
|
||
- The shell's own surfaces cannot be matched: refuse classes matching `^(quickshell|qs-)`.
|
||
- New contract `tests/hypr/window-rules-contract` cloned from `workspace-rules-contract`'s
|
||
stub-lua harness: synthetic `settings.json` with N rules (one invalid, one `qs-` class) →
|
||
assert shipped count + N−2 emitted, escaping applied, invalid skipped.
|
||
|
||
## 3. Gestures
|
||
|
||
- `input.lua`: the three shipped 3-finger gestures stay **verbatim**. After them, for each of
|
||
the four `gestureFour*` prefs that resolves to a valid named action, emit
|
||
`hl.gesture({ fingers = 4, direction = <up/down/left/right>, action = <resolved> })`.
|
||
- MousePage: new "Gestures" card above Touchpad per the mock — three read-only shipped rows,
|
||
four OptionPickerRows (options: Nothing + the named-action vocabulary; assigning writes the
|
||
pref and triggers `Keybinds.applyReload()` — reuse that seam, do not build a second reload),
|
||
then `swipeDistance` + `swipeInvert` moved up from the Touchpad card (schema keys unchanged —
|
||
`search-routing-contract` keeps passing because the group's keys stay on the same page).
|
||
- `tests/hypr/gestures-contract` rewrite: the three 3-finger pins stay verbatim (including the
|
||
no-`overview("toggle")` rule); the `== 3` count becomes "exactly 3 three-finger literals, plus
|
||
four-finger emission only inside the customGestures loop"; add a stub-lua run with a synthetic
|
||
settings.json asserting 0 four-finger gestures when unassigned and N when assigned, and that
|
||
`swipeDistance`/`swipeInvert` remain schema keys.
|
||
|
||
## 4. Network
|
||
|
||
New `panama-network` verbs (all: `shape_for` + `FALLBACKS` entries, `connection_state()` reply
|
||
shape for per-connection mutations, no absolute binary paths, secrets never in argv):
|
||
|
||
- `set-ip CONNECTION 4|6 auto` / `set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS…]`
|
||
— `ipv4/ipv6.method`, `.addresses`, `.gateway`, `.dns`, `.ignore-auto-dns yes` on manual;
|
||
clears them on auto. New validation regexes (IPv4, IPv6, CIDR prefix ranges) in house style.
|
||
Reactivates the connection after the change (`nmcli connection up`) only if it was active.
|
||
- `details` additionally reports the *profile's* configured method + addresses/gateway/dns for
|
||
both stacks (today it only reads active state) and `metered` (`connection.metered`).
|
||
- `set-metered CONNECTION yes|no|auto`.
|
||
- `saved` — `nmcli connection show` listing (name, uuid, type, autoconnect, last-used,
|
||
in-range flag by cross-referencing active scan), secrets filtered.
|
||
- `join-hidden SSID PROFILE SECURITY` — password on stdin, `wifi.hidden yes`; security ∈
|
||
`wpa-psk|sae|none`.
|
||
- NetworkTools.qml: plumbing for each verb (same queued-Process pattern), drafts live in the
|
||
page.
|
||
- ConnectionDetails.qml: metered toggle + the IPv4/IPv6 editors from the mock (Automatic/Manual
|
||
choice; manual reveals address/gateway/DNS drafts; nothing applies until Apply; commit only
|
||
when the address validates — the proxy-drafts pattern from ConnectivityPage).
|
||
- ConnectivityPage: "Saved networks" card (house cap 6 + fold, in-range/connected/autoconnect
|
||
detail lines, Forget via ConfirmAction naming the password loss). WifiPanel: "Join a hidden
|
||
network…" row → SSID + security + password flow (reuse the join-path components).
|
||
- `network-tools-contract`: static half — new regexes exist, `shape_for` knows the new verbs,
|
||
AST secret check still passes; dynamic half — stubbed nmcli sees the right argv for set-ip
|
||
manual/auto, set-metered, join-hidden (password via stdin only, sentinel never in argv/JSON).
|
||
|
||
## 5. Color filters
|
||
|
||
- Ship 4 shaders under `config/dot/hypr/shaders/` (grayscale, protanopia, deuteranopia,
|
||
tritanopia — the mock's feColorMatrix values, as Hyprland screen shaders; end-of-pipe
|
||
`vec4 → vec4` GLSL). `declared-assets-contract`: they're installed by the existing hypr dir
|
||
symlink — verify, and add whatever declaration that contract wants.
|
||
- `looks.lua`: `local filter = prefs.get("colorFilter", "none")` → table lookup enum→absolute
|
||
shader path → `decoration.screen_shader` (empty string when none/unknown).
|
||
- Live apply: `SystemSettings` gains `applyColorFilter(name)` doing the same lookup +
|
||
`hyprctl eval decoration:screen_shader <path>` with read-back; the Accessibility page's
|
||
ChoiceRow (Seeing card, per mock) writes the pref and calls it. No `hypr:` block on the schema
|
||
entry (stored enum ≠ hyprctl's path value, which would break the shape/sweep contracts).
|
||
- `hypr-prefs-contract` requires the `prefs.get` default to match the schema default (`"none"`).
|
||
|
||
## 6. Smaller items
|
||
|
||
- **DND tile** (QuickSettingsPanel): beside Presentation, bound to `Notifs.doNotDisturb`,
|
||
subtitle On/Off; Presentation keeps its combined role. Check `control-center-contract` +
|
||
`control-center-services-contract` pins before and after.
|
||
- **Search tokenization** (SettingsSearch.qml): the needle splits on whitespace; every token
|
||
must match the haystack (AND). Ranking gains "all tokens in label" above the existing rules.
|
||
Keep the empty-query → `[]` behavior and all 21 pinned cases in `settings-search-contract`
|
||
(they are single-token and must not regress). Add `extraEntries` for every new Tier-2 surface
|
||
(custom shortcuts, app rules, gestures, saved networks, hidden network, metered, static IP,
|
||
color filter, DND tile) and synonyms for the known holes (`log out`, `wired`, `ethernet`,
|
||
`gestures`, `metered`). Results may carry an optional `section`; SettingsSidebar uses
|
||
`ShellState.openSettingsSection(page, section)` when present, else `pageRequested` as today.
|
||
- **Known hosts cap** (SshKeysPage): house cap 6 + "N more ▾" fold on the hosts Repeater.
|
||
- **GNOME umbrella button** (HealthPage): remove the "Fedora system settings" card
|
||
(`openGnomePanel("system")`). Wellbeing card stays. Add `[system]=about` to
|
||
`gnome-handoff-contract`'s OWNED map so the door stays shut.
|
||
|
||
## Ownership (disjoint)
|
||
|
||
- **Agent A — the Lua plane**: `config/dot/hypr/actions.lua` (new), `keybinds.lua`, `input.lua`,
|
||
`rules.lua`, `looks.lua`, `shaders/` (new); `tests/hypr/gestures-contract`,
|
||
`keybind-categories-contract`, `window-rules-contract` (new), `tests/quickshell/keybind-rebind-contract`.
|
||
- **Agent B — input-block services & UI**: `services/Keybinds.qml`, `services/WindowRules.qml`
|
||
(new), `services/SystemSettings.qml` (applyColorFilter only), `modules/settings/ShortcutsPage.qml`,
|
||
`TilingPage.qml`, `MousePage.qml`, the Accessibility Seeing page (color filter row),
|
||
`modules/settings/qmldir` + any new components; `tests/quickshell/keybinds-contract` needles if
|
||
needed.
|
||
- **Agent C — network & the rest**: `scripts/panama-network`, `services/NetworkTools.qml`,
|
||
`services/SettingsSearch.qml`, `modules/settings/ConnectivityPage.qml`, `ConnectionDetails.qml`,
|
||
`WifiPanel.qml`, `SettingsSidebar.qml`, `HealthPage.qml`, `SshKeysPage.qml`,
|
||
`modules/quicksettings/QuickSettingsPanel.qml`; `tests/quickshell/network-tools-contract`,
|
||
`settings-search-contract` (additions only), `gnome-handoff-contract` (OWNED addition),
|
||
`ssh-keys-contract` (cap needle if needed).
|
||
- **Orchestrator**: `PreferenceSchema.qml` (done first), `SettingsRoutes`/docs/commands regen,
|
||
seam audit, `settings-ownership-contract` if mirrors appear.
|
||
|
||
## Standing rules
|
||
|
||
Live desktop: every save must leave valid QML; check the journal (errors AND "Unable to
|
||
assign" warnings) after each. No live mutations: no real nmcli writes, no hyprctl reload storms
|
||
(batch: reload once per verified change-set, announce). Contracts run as you go (grant active);
|
||
never `settings-system-contract`. Theme tokens only; no continuously repainting animations;
|
||
destructive actions via ConfirmAction; errors via ErrorRow; unmeasured via NotMeasuredRow.
|