13 KiB
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:
{ "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 verbsshell.qmlalready 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:Nvalidated 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, acategory("Custom")loop overprefs.get("customBinds", {}). Each entry:valid_chord(chord)must pass,labelnon-empty (it becomes the bind description —keybinds-contractfails builds with description-less binds), action resolved viaactions.luaor the entry is skipped. Custom chords must also not collide with an already-emitted chord (skip + keep the shipped one; QML prevents this upstream).keybindOverridesstays chord-only and keys by shipped chord; custom binds are edited in place incustomBinds(rebind rewrites the entry'schord), so the two mechanisms never meet inshippedChordFor/overrideOccupantFor. Keybinds.qml must exclude custom chords from the override map's domain.- Keybinds.qml grows:
customBindsreader,addCustomBind(chord, kind, target, label),rebindCustomBind,removeCustomBind(all conflict-checked viaboundTo, all ending inapplyReload()),describeAction(entry).groupOrdergainsCustomfirst. - 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— addCustomtoknown(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'skind∈ the enum,targetmatches the safe regex,chord< 64 chars, and a static check thatactions.luabuilds exec strings only from its own whitelist tables (noentry.targetconcatenated into a command except as a quoted argv element).
2. Window rules
Stored shape:
{ "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 overprefs.get("windowRules", {})emitting onehl.window_ruleper 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.qmlsingleton modeled onWorkspaces.qml: owns the pref array,addRule/updateRule/removeRule, the 120 ms write-settle +hyprctl reload+ 350 ms refresh, and an honestappliedreadback (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 viaHyprland.toplevelsfirst, 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-contractcloned fromworkspace-rules-contract's stub-lua harness: syntheticsettings.jsonwith N rules (one invalid, oneqs-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 fourgestureFour*prefs that resolves to a valid named action, emithl.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), thenswipeDistance+swipeInvertmoved up from the Touchpad card (schema keys unchanged —search-routing-contractkeeps passing because the group's keys stay on the same page). tests/hypr/gestures-contractrewrite: the three 3-finger pins stay verbatim (including the no-overview("toggle")rule); the== 3count 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 thatswipeDistance/swipeInvertremain 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 yeson 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.detailsadditionally reports the profile's configured method + addresses/gateway/dns for both stacks (today it only reads active state) andmetered(connection.metered).set-metered CONNECTION yes|no|auto.saved—nmcli connection showlisting (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_forknows 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-pipevec4 → vec4GLSL).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:
SystemSettingsgainsapplyColorFilter(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. Nohypr:block on the schema entry (stored enum ≠ hyprctl's path value, which would break the shape/sweep contracts). hypr-prefs-contractrequires theprefs.getdefault 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. Checkcontrol-center-contract+control-center-services-contractpins 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 insettings-search-contract(they are single-token and must not regress). AddextraEntriesfor 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 optionalsection; SettingsSidebar usesShellState.openSettingsSection(page, section)when present, elsepageRequestedas 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]=abouttognome-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-contractneedles 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-contractif 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.