Files
Panama/docs/superpowers/specs/2026-08-25-tier2-identity.md

13 KiB
Raw Permalink Blame History

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 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 110).

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:

{ "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 + N2 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.
  • savednmcli 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.