# 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": "", "label": "" } ``` - `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": "", "label": "", "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 = , action = })`. - 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 ` 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.