Shortcuts you invent, rules you write, gestures you own - all still just data
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user