Skip to content

Commit 69c43f6

Browse files
committed
Merge feat/universal-device: universal device support (any mouse/macropad)
ControlId is an open string type, devices are first-class config entities (schema v3, v2 configs migrate on load), learn-mode adds buttons of any programmable device via DOM key capture, user devices get an optional photo with click-to-place hotspots, Razer conveniences are gated to the built-in Naga device.
2 parents fa1a88f + 3ef061c commit 69c43f6

38 files changed

Lines changed: 3382 additions & 409 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Windows-first desktop app that remaps a programmable mouse (Razer Naga) and keyb
9393
- Code comments in English.
9494
- All user-facing text goes through i18next with matching `en.json` + `ru.json` keys — never hardcode UI strings.
9595
- No AI/assistant attribution anywhere (commits, PRs, code, docs).
96-
- Config is the source of truth: schema-first (`schemas/config.v2.schema.json` at the repo root, runtime-validated), atomic writes, rolling + daily backups, corrupt-recovery. The TS `AppConfig` (`src/lib/config.ts`) and Rust structs must stay in sync with the schema.
96+
- Config is the source of truth: schema-first (`schemas/config.v3.schema.json` at the repo root, runtime-validated), atomic writes, rolling + daily backups, corrupt-recovery. The TS `AppConfig` (`src/lib/config.ts`) and Rust structs must stay in sync with the schema.
9797
- Adding an `ActionType` touches many mirrored sites — see `src/components/action-picker/AGENTS.md`.
9898
- Fix root causes, not symptoms. Reuse existing shared primitives/helpers before adding new ones.
9999

DESIGN_SYSTEM.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ all wired into `.github/workflows/ci.yml` (`npm test` + `cargo test`).
1313

1414
| Need | Canon home | Adoption guard |
1515
|------|-----------|----------------|
16-
| **ActionType** (3 sources of truth) | Rust `src-tauri/src/config.rs:538` `ActionType::ALL` ⇄ FE `src/lib/constants/ui-copy.ts:56` `ACTION_TYPE_LABELS``:73` `ALL_ACTION_TYPES` ⇄ schema `schemas/config.v2.schema.json` `$defs.actionType.enum` | `config.rs` test `action_type_set_matches_schema_enum` (`:4283`, set-equality Rust↔schema) + `:553` compile-exhaustive `match` (no `_` arm) + FE `Record<ActionType, …>` (won't compile without label+icon). A missed schema entry is a **save-breaker** — this is the highest-radius canon. |
16+
| **ActionType** (3 sources of truth) | Rust `src-tauri/src/config.rs:538` `ActionType::ALL` ⇄ FE `src/lib/constants/ui-copy.ts:56` `ACTION_TYPE_LABELS``:73` `ALL_ACTION_TYPES` ⇄ schema `schemas/config.v3.schema.json` `$defs.actionType.enum` | `config.rs` test `action_type_set_matches_schema_enum` (`:4283`, set-equality Rust↔schema) + `:553` compile-exhaustive `match` (no `_` arm) + FE `Record<ActionType, …>` (won't compile without label+icon). A missed schema entry is a **save-breaker** — this is the highest-radius canon. |
1717
| FE action-type lists | `ui-copy.ts` `editableActionTypes`/`ACTION_CATEGORIES`/`ACTION_TYPE_ICONS` | All derived from `ALL_ACTION_TYPES` — structurally cannot drift from the type set. |
1818
| **MouseActionKind / MediaKeyKind** (3 SoT, same shape as ActionType) | Rust `config.rs` `MouseActionKind`/`MediaKeyKind` enums ⇄ schema `$defs.mouseActionKind`/`mediaKeyKind` ⇄ FE `ui-copy.ts` `MOUSE_ACTION_LABELS`/`MEDIA_KEY_LABELS` → derived `*_OPTIONS` | `config.rs` tests `mouse_action_kind_set_matches_schema_enum` + `media_key_kind_set_matches_schema_enum` (set-equality Rust↔schema, strings via serde) + per-enum compile-exhaustive `match` (no `_`) + FE `Record<…Kind>` (won't compile without a label). The payload `$ref`s the enum, so a bad value is a clear `schema_violation`, not a deserialize parse-error. |
1919
| `<select>` dropdown | `src/components/shared.tsx` `SelectField` | `src/lib/canon-guards.test.ts:41` — raw `<select>` fails CI outside `SelectField` + 4 documented exceptions. |
@@ -31,7 +31,7 @@ all wired into `.github/workflows/ci.yml` (`npm test` + `cargo test`).
3131

3232
Touch **all four** or a guard fails (which is the point):
3333
1. `config.rs` `ActionType` enum + `ActionType::ALL` (`:538`) + the exhaustive `match` (`:553`).
34-
2. `schemas/config.v2.schema.json` `$defs.actionType.enum`.
34+
2. `schemas/config.v3.schema.json` `$defs.actionType.enum`.
3535
3. FE `ui-copy.ts` `ACTION_TYPE_LABELS` + `ACTION_TYPE_ICONS` (compile-guarded `Record`s).
3636
4. Run `cargo test` (set-equality) + `npm test` (FE compile/derive). See `reference_add_action_type_checklist` in memory.
3737

docs/CONFIG-SCHEMA-V2.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Config Schema V2
22

3+
> **Superseded by v3** (universal-device support): see `docs/CONFIG-SCHEMA-V3.md`
4+
> for the delta. The bundled runtime schema is now `schemas/config.v3.schema.json`.
5+
36
- Status: Working draft aligned with accepted ADRs
47
- Date: 2026-03-07
58
- Canonical file name: `config.json`

docs/CONFIG-SCHEMA-V3.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Config Schema V3
2+
3+
- Status: Current
4+
- Date: 2026-07-05
5+
- Canonical file name: `config.json`
6+
- Canonical schema file: `schemas/config.v3.schema.json`
7+
- Base document: `docs/CONFIG-SCHEMA-V2.md` (v3 is v2 plus the delta below; all
8+
v2 rules not listed here still apply)
9+
10+
## Why v3
11+
12+
Sidearm supports any programmable input device, not only the Razer Naga. The
13+
closed, Naga-specific `controlId` enum became an open token, and devices became
14+
first-class persisted entities.
15+
16+
## Delta from v2
17+
18+
### `controlId` is open
19+
20+
- v2: closed 27-value enum (`thumb_01``top_special_03`).
21+
- v3: any token matching `^[a-z0-9][a-z0-9_-]*$` (max 64 chars).
22+
- Referential integrity moved from the schema into application validation
23+
(`validate_config` in `src-tauri/src/config.rs`):
24+
- every `bindings[].controlId`, `bindings[].chordPartner`,
25+
`encoderMappings[].controlId`, and `devices[].hotspots[].controlId` must
26+
exist in `physicalControls`
27+
- every `physicalControls[].deviceId` must reference a declared device
28+
29+
### New top-level entity: `devices`
30+
31+
```json
32+
{
33+
"id": "razer-naga",
34+
"name": "Razer Naga V2 Hyperspeed",
35+
"builtin": true,
36+
"image": "photo.png",
37+
"hotspots": [{ "controlId": "thumb_01", "x": 25.0, "y": 75.5 }]
38+
}
39+
```
40+
41+
- `id`, `name` required; `builtin` defaults to false.
42+
- `image` is a **bare file name** resolved inside the app-data devices
43+
directory (path separators and `..` are rejected) — used by user devices.
44+
The built-in Naga device renders through frontend-bundled photos instead.
45+
- `hotspots` place controls on the image in percent (0..=100) of its size.
46+
- At least one device must be declared.
47+
- A config declaring the built-in Naga device (`razer-naga`) must contain all
48+
27 Naga controls in `physicalControls` — the v2 completeness invariant,
49+
scoped to that device.
50+
51+
### `physicalControls[].deviceId`
52+
53+
Each control is tagged with the device it belongs to. Optional in the schema
54+
(defaults to `razer-naga` in serde) so v2 files stay valid.
55+
56+
### Version and migration
57+
58+
- `version` accepts `2` or `3` at the schema layer; `validate_config` requires
59+
the migrated in-memory config to be exactly `3`.
60+
- A v2 file loads unchanged: `migrate_devices` seeds the built-in Naga device,
61+
serde defaults tag legacy controls with `razer-naga`, and the version is
62+
bumped to 3 on the next save. Bindings, encoder mappings, and actions are
63+
untouched.
64+
- An app older than v3 refuses a v3 file (its v2 schema pins `version` to 2)
65+
and, per the newer-schema guard, never overwrites or "recovers" it.

docs/DEVICE-CATALOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ The control appears remappable in some form, but behavior may be duplicated, uns
101101
## Persistence Implications
102102

103103
- Every row in this catalog should correspond to a `physicalControls[]` entry in config v2.
104-
- The `controlId` set here must remain in sync with `schemas/config.v2.schema.json`.
104+
- The `controlId` set here must remain in sync with `schemas/config.v3.schema.json`.
105105
- If a control name improves later, update `defaultName` or `synapseName`, but preserve `controlId`.
106106

107107
## Known Catalog Debt

schemas/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ The runtime-validated JSON Schema for `AppConfig` — the single source of truth
66

77
## Ownership
88

9-
- `config.v2.schema.json` — bundled into `src-tauri/src/config.rs` via `include_str!("../schemas/config.v2.schema.json")`.
9+
- `config.v3.schema.json` — bundled into `src-tauri/src/config.rs` via `include_str!("../schemas/config.v3.schema.json")`. Accepts `version` 2 or 3: a v2 file (no `devices`/`deviceId`) is schema-valid and migrated in code (`migrate_devices`) on load.
10+
- `config.v2.schema.json` — historical (v0.x–v0.7.5 contract), no longer bundled. Kept for reference; see `docs/CONFIG-SCHEMA-V3.md` for the v2→v3 delta.
1011

1112
## Local Contracts
1213

1314
- This schema, the Rust structs (`src-tauri/src/config.rs`), and the TS types (`src/lib/config.ts`) form a three-way contract — a change in any one requires the other two.
1415
- Enum lists (`$defs.actionType.enum`, `$defs.mouseActionKind.enum`, `$defs.mediaKeyKind.enum`) must match the Rust enums; `config.rs` has schema-sync tests that assert this.
16+
- `$defs.controlId` is an OPEN pattern since v3 (any device's control token). Referential integrity (control ∈ `physicalControls`, control's `deviceId``devices`) is enforced by `validate_config` in Rust, not by the schema.
1517
- A persisted field rename = `anyOf`(both names) here + `#[serde(alias)]` in Rust, so older configs still load.
1618
- `version` is pinned to `SCHEMA_VERSION`; bump it deliberately (a newer-version config is never overwritten by an older app).
1719

0 commit comments

Comments
 (0)