> For the complete documentation index, see [llms.txt](https://docs.kouch.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kouch.dev/docs/architecture.md).

# Kouch — Architecture and implementation plan (Phase 1 + Phase 2)

> **Since 2026-09-30 (ADR 0026):** Kouch seats at most 8 players, every one with motion, on two DSU servers (26760–26761; seats 1–8 keep their ports). The 16-player figures in this document are historical.

> Written 2026-09-17 from the approved planning session. Sections that describe *decisions* are authoritative; sections that describe *verified facts* cite what was checked and mark the rest UNVERIFIED. Update this file when a decision changes; add an ADR under `docs/adr/` for anything that reverses an earlier one.

## Context

Kouch is a Steam app (working name; trademark/store checks pending) that acts as an emulator frontend and controller hub: up to 16 players read through Steam Input (8 with motion), a claim-a-slot screen, **four DSU/Cemuhook servers** feeding user-installed emulators (plus optional virtual gamepads for rumble), a paged tile-grid frontend, and a Quick Menu overlay. Solo dev with friends helping on themes, launch configs, testing, and store art.

This document covers **Phase 1 (input core) and Phase 2 (frontend basics + Quick Menu)** of the roadmap in `FEATURE_PLAN.md`, plus the repo/design-system foundation everything later builds on. Later phases get interfaces/stubs only.

**Why DSU instead of virtual gamepads:** the reading side must be Steam Input (Steam Controllers, the Deck, and any pad Steam claims only expose gyro through it). The emulator is a separate process that cannot see Steam Input, and Steam's own gamepad emulation gives a child process at most 4 XInput pads. The two ways to hand it 16 players are virtual gamepad devices (Linux: kernel uinput, no driver; Windows: only via a third-party bus driver — ViGEmBus is archived, its successor commercial) or the DSU/Cemuhook UDP protocol, which carries buttons + sticks + motion and needs no driver on either OS. DSU is the primary Phase 1–2 output; the "all 16 slots always exist" policy below makes connect/disconnect/reorder invisible to the emulator. Virtual pads exist for rumble and for emulators without a DSU button client.

**How to read this file:** Decisions → Verified facts → Milestone 0 → Backend plan → Execution order → Brand blocklist → Early Tests → Verification → Frontend plan. The UI design system is `DESIGN.md`.

## Decisions locked in with the user (2026-09-17)

| Topic                                                         | Decision                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Stack                                                         | **Rust core + Tauri 2 web UI.** Rust owns Steam Input, virtual pads, DSU, HID, launch; UI is HTML/CSS/JS in a webview.                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ES-DE                                                         | Not forked. (ES-DE is C++/CMake/SDL2/OpenGL 3.3, MIT — "Copyright (c) 2024-2026 Northwestern Software AB"; XML themes.) Kouch has its own theme format; no ES-DE theme support.                                                                                                                                                                                                                                                                                                                                                                         |
| Frontend look                                                 | **Paged tile-grid launcher** (owner's reference: a console's system menu on a handheld's touchscreen — the console is never named in repo/docs/code): rounded square tiles, paged horizontally, big focus state, status strip, bottom bar.                                                                                                                                                                                                                                                                                                              |
| Theme                                                         | **OLED-first: true `#000000` background**, near-black surfaces with hairline borders, no glow/low-alpha gradients. Light theme = token swap later.                                                                                                                                                                                                                                                                                                                                                                                                      |
| Design enforcement                                            | Author a **DESIGN.md design system** (10-foot typography, spacing, tokens, focus, motion, safe areas, controller nav) **and follow Valve Big Picture / Deck conventions** (glyph prompts, B = back, focus rings). Every screen must comply; Workshop themes override tokens + images only.                                                                                                                                                                                                                                                              |
| Glyphs                                                        | Steam Input glyphs at runtime (`GetGlyphSVGForActionOrigin`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| Steamworks                                                    | No partner account yet → **develop against Spacewar (app ID 480)**.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| License                                                       | Private, all rights reserved for now; LICENSE placeholder + CONTRIBUTING assignment note. Decide before EA.                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Repo layout                                                   | **Monorepo**: `app/`, `companion/`, `decky/`, `mobile/`, `docs/`. GitHub updater stays a separate org/repo.                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Dev machine baseline                                          | Rust 1.93 (cargo), Node 24, VS 2022 Build Tools (MSVC), gh CLI, Steam client.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| Platforms                                                     | **Windows + Linux/Deck from day one.** Test hardware: a Steam Deck and 8+ physical controllers.                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| **Output to emulators**                                       | **DSU/Cemuhook is the primary output in Phase 1–2**, both OSes. **4 servers** (127.0.0.1:26760–26763, configurable) × 4 slots = 16 players; motion on whichever 8 slots hold a motion assignment. All 16 slots always exist → emulator mapping never shifts on connect/disconnect/reorder; Kouch changes which controller feeds which slot. Kouch never *requires* a driver to run.                                                                                                                                                                     |
| **Rumble** (owner decision after review)                      | **A + B now, C opt-in.** (A) DSU servers implement the unofficial rumble extension (`0x110001` info / `0x110002` rumble) and forward it to Steam Input `TriggerVibration`. (B) **Linux uinput virtual pads with force feedback are real Phase 2 work** (Milestone 10), giving the Deck full rumble in every emulator. (C) **Windows: opt-in per-profile ViGEmBus backend** via `vigem-client` (Xbox360 targets), offered with a one-click install only when a profile enables "virtual pads"; behind the `VirtualPad` trait so the driver is swappable. |
| Colors                                                        | **No gradients.** Black `#000`, white at stepped opacities, ONE accent = OS accent color when readable on black, else/also user-customizable, default light pastel purple. Player palette (16) is separate and fixed.                                                                                                                                                                                                                                                                                                                                   |
| Reference emulators (local test profiles only, never shipped) | Four reference emulators, all the owner's choice and never named in repo/docs: one full-DSU-class (buttons + motion), one DSU-motion-only-class, one no-DSU-class (exercises the uinput stretch on Deck), plus one hybrid-console-class emulator.                                                                                                                                                                                                                                                                                                       |
| Double-input                                                  | Emulator profiles default to "ignore native gamepads": Kouch edits the emulator's own config to disable its SDL/XInput backend and enable DSU, with per-profile documentation and a user override.                                                                                                                                                                                                                                                                                                                                                      |
| Quick Menu summon                                             | Chord (hold Select/View + Start/Menu \~1 s) by default **and** a bindable "Open Quick Menu" Steam Input action.                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| Rust targets                                                  | `x86_64-pc-windows-msvc` locally; `x86_64-unknown-linux-gnu` via CI and the Deck.                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| Dropbox                                                       | The project folder lives inside Dropbox. `target/` and `node_modules/` must be excluded from Dropbox sync (Dropbox "ignore" attribute set in the setup step) or builds will thrash sync.                                                                                                                                                                                                                                                                                                                                                                |
| Plan B if Early Test #0/#1 fails                              | Buy the $100 Steam Direct slot early for a real app ID (needed before EA anyway).                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| Library index                                                 | SQLite via `rusqlite` (bundled).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| Brand blocklist                                               | I draft `docs/brand-blocklist.txt`; owner reviews before lint enforces it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

## Ground rules (from feature plan — non-negotiable)

* No excluded-platform emulator names, brand names, or imagery anywhere (store page, app, marketing, **code comments and asset names included**).
* Never ship or transfer ROMs, BIOS, firmware, or keys.
* Workshop content is data only unless sandboxed.
* Emulators are user-installed by default.

## Verified facts that shape the plan (research agent, 2026-09-17)

**Steamworks / Rust bindings**

* `steamworks` crate 0.13.1 (2026-05-05) / `steamworks-sys` 0.13.0 bundles **Steamworks SDK 1.64** headers *and* redistributables (`win64/steam_api64.dll+.lib`, `linux64/libsteam_api.so`) inside the crate; `build.rs` defaults to the bundled copy unless `STEAM_SDK_LOCATION` is set. → **No SDK download needed to build.** MSRV 1.80. Files under `lib/steam/` are under Valve's SDK terms, not MIT/Apache.
* Downloading the SDK from partner.steamgames.com only needs a free Steam login (not the $100 Steam Direct fee).
* Flat C exports confirmed in the bindgen output: `SteamAPI_ISteamInput_TriggerVibration`, `_TriggerVibrationExtended`, `_TriggerSimpleHapticEvent`, `_SetLEDColor`, `_SetDualSenseTriggerEffect`, `_GetGamepadIndexForController`, `_GetGlyphPNGForActionOrigin`, `_BNewDataAvailable`, `_EnableDeviceCallbacks`, `_GetMotionData`, `_SetInputActionManifestFilePath`, `_GetSessionInputConfigurationSettings`. The high-level `steamworks::Input` wrapper **lacks** vibration / LED / gamepad-index → Kouch wraps those raw `steamworks_sys` calls itself.
* `STEAM_INPUT_MAX_COUNT = 16`. `Init(bool bExplicitlyCallRunFrame)`; `RunFrame()` must be called before `GetConnectedControllers()` returns handles.
* `InputMotionData_t` (packed, 40 B): rotQuat xyzw, posAccel xyz (±SHRT\_MAX ⇒ ±2 G), rotVel xyz (±SHRT\_MAX ⇒ ±2000 °/s). Accel/gyro are "latest packet only".
* `ESteamInputType`: `PS4Controller` = DualShock 4, `PS5Controller` = DualSense, `SteamDeckController`, `SwitchProController`, `MobileTouch`, `GenericGamepad`; **`SwitchJoyConPair` / `SwitchJoyConSingle` are marked "Unused"** in SDK 1.64 → Phase 3 split-pair detection cannot rely on the enum (risk; needs HID VID/PID or config-file detection).
* Callbacks: `SteamInputDeviceConnected_t`, `SteamInputDeviceDisconnected_t`, `SteamInputConfigurationLoaded_t` ("fires once per controller **per focus change**"), `SteamInputGamepadSlotChange_t` (Linux/macOS gamepad slots shared across apps).
* Action manifest: gyro is not an action type; motion always comes from `GetMotionData()`. Per-controller-type default bindings go in the Action Manifest `"configurations"` block, keyed by `controller_xboxone`, `controller_ps4`, `controller_ps5`, `controller_switch_pro`, `controller_switch_joycon_left/right/pair`, `controller_neptune`, `controller_generic`, … pointing at `.vdf` files exported from Big Picture developer mode (`steam://dumpcontrollerconfig?appid=…`). There is **no built-in "raw 1:1 gamepad" shortcut** — Kouch must ship one action set with every button/stick/trigger as an action plus default `.vdf` configs per controller type.
* `SetInputActionManifestFilePath()` exists exactly for local/bundled manifests; call order vs `Init()` undocumented (convention: before).
* Spacewar dev: `steam_appid.txt` containing `480` beside the exe, Steam running. Steam Input is **not** among Spacewar's listed demo features; whether `ISteamInput` fully works under 480 with a local manifest is **UNVERIFIED → Early Test #0**.
* Whether Steam Input data keeps flowing when a child-process window has focus is **undocumented** → Early Test #1 stays a real risk; the plan includes a fallback (see Risks).

**Virtual gamepads — Windows**

* ViGEmBus archived 2023-11-02 (trademark dispute with "ViGEM GmbH"; BSD-3; last signed release still installs). Successor **Nefarius VirtualPad is commercial, B2B-only, no public SDK**. ScpVBus archived 2018. HidHide (MIT) is a filter driver that hides physical HID devices from chosen processes — still relevant, low-activity maintenance.
* `vigem-client` 0.1.4 (2022, pure Rust, `Xbox360Wired` + `DualShock4Wired` targets). `vjoy` 0.7.1 (2025-12-19) wraps vJoy (DirectInput joystick; njz3 fork MIT; signed 2.2.2.0 builds published by BrunnerInnovation/vJoy).
* XInput hard-caps at 4 controllers (`XUSER_MAX_COUNT`, indices 0–3) — documented API limit, so players 5–16 can never be XInput devices.

**Virtual gamepads — Linux/Deck**

* `evdev` 0.13.2 (2025-09-15): `VirtualDevice::builder()` with `.with_ff()`/`.with_ff_effects_max()`, and `process_ff_upload`/`process_ff_erase` → **rumble round-trip from emulator to app works**.
* SteamOS/steam-devices `60-steam-input.rules`: `KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"` → seat user can open `/dev/uinput` without group changes.

**DSU / Cemuhook** (spec: github.com/v1993/cemuhook-protocol)

* 16-byte LE header: magic `DSUS`/`DSUC`, version `1001`, payload len, CRC32 (computed with field zeroed), sender id; then u32 message type `0x100000` version / `0x100001` controller info / `0x100002` controller data. Max 4 slots per server. Data payload 100 B: 11-byte shared header (slot, state, model 0/1/2, connection USB/BT, MAC, battery), connected flag, packet #, 2 button bytes, HOME/touch, sticks (128 = center), 8 analog buttons, 2×6-byte touch, u64 µs motion timestamp @48, accel xyz f32 in G @56, gyro pitch/yaw/roll f32 °/s @68. No disconnect message; server drops clients that stop re-requesting (\~5 s convention).
* Several reference emulators for excluded console platforms all accept configurable `ip:port` DSU servers (one of them: multiple servers).
* **Measured 2026-09-17 against the full-DSU-class reference emulator:** its client marks a server unresponsive when no PortInfo has arrived within 1 s of the previous one, and polls exactly every 1 s — so a reply-only server flaps every device on every cycle. Kouch therefore pushes unsolicited PortInfo to each recent poller every 500 ms (`DsuServer::refresh_port_info`). It names devices `DSUClient/<slot>/<server description>`, creates a device only for slots with state = Connected (2), exposes inputs `Cross/Circle/Square/Triangle, L1/R1, L2/R2, L3/R3, Share/Options, Pad N/S/E/W, Left/Right X±/Y±, Accel Up/Down/Left/Right/Forward/Backward, Gyro Pitch/Roll/Yaw ±`, and re-enumerates only when slot state/model/connection/MAC change (battery excluded).

**Tauri 2**

* Stable **2.11.5** (2026-07-01), MSRV 1.77.2. Linux backend WebKitGTK 4.1. Windows/Linux `transparent(true)` needs no feature flag. `shadow(false)` required on Windows for an undecorated overlay (otherwise 1px white border + rounded corners on Win11). `set_ignore_cursor_events(true)` = click-through. `visible_on_all_workspaces` unsupported on Windows. `WebviewWindowBuilder` can create windows on demand from Rust. `tauri-plugin-global-shortcut` exists (desktop only).

**Raw HID (Phase 8, noted now)**

* `hidapi` 2.6.7 (2026-08-27); `windows-native` and `linux-native` backends. Whether it can open a device Steam already holds: UNVERIFIED. `sdl3` 0.20.0 does **not** expose SDL's HID API to Rust — use `hidapi` directly.

**gamescope overlay (Deck Game Mode)**

* **Verified 2026-09-26 in real Game Mode (Early Test 10 PASS), superseding the first plan.** `GAMESCOPE_EXTERNAL_OVERLAY` is the wrong tool. gamescope draws external overlays only from its root Xwayland (Steam's), while games and Kouch run on another one. Marking a window also zeroes its app id (`if (w->isExternalOverlay) w->appID = 0;` in `steamcompmgr.cpp`).
* **What works** is gamescope's same-app override: an override-redirect window whose app id matches the focused game's (`override->appID == focus->appID` under Steam's focus control) is drawn over it. Kouch's Quick Menu is such a window. It is pinned to the panel size with min = max `WM_NORMAL_HINTS` set before its first map: GTK/WebKit give the window one 1×1 child, and without pinned hints `get_size_hints` takes it for an old SDL fullscreen wrapper (`ignoreOverrideRedirect`), which sticks for the window's life. Code: `app/src-tauri/src/overlay.rs`, `kouch_launch::linux`.
* **Prerequisite:** the game must be in the same app as Kouch. Emulators inherit Steam's launch ids, not the one Kouch's Steam init set (`kouch_launch::capture_launch_env`); otherwise gamescope never shows the game at all.

**UI stack facts (design agent, verified)**

* Svelte 5.57 / Vite 8.3 / Vitest 5.0 / `@tauri-apps/api` 2.11.1. Tauri event names may contain only `[A-Za-z0-9-/:_]` (no dots). Linux webview = WebKitGTK; devtools via Ctrl+Shift+I; `tauri-driver` exists for Windows + Linux only. `performance.measureUserAgentSpecificMemory` is absent in WebKit.
* Inter variable font is SIL OFL 1.1. GNOME accent key `org.gnome.desktop.interface accent-color` (enum → libadwaita hex table); Windows accent via `UISettings::GetColorValue(UIColorType::Accent)` (`windows` crate feature `UI_ViewManagement`). KDE `kdeglobals` `[General] AccentColor=r,g,b`, else `[Colors:Selection] BackgroundNormal` (checked on the Deck's Desktop Mode, 2026-09-28); `theme.rs::os_accent_hex` picks KDE or GNOME by `XDG_CURRENT_DESKTOP`.
* Safe `steamworks` crate exposes only the PNG glyph path; `GetGlyphSVGForActionOrigin` must go through `steamworks_sys`.

***

## Milestone 0 — Repo foundation (before any feature code)

Everything below lives at the repo root and is pushed to `golfista/kouch` `main`.

1. `git clone https://github.com/golfista/kouch .` (folder is empty; the remote has only `README.md`).
2. Dropbox: mark `target/`, `node_modules/`, `app/ui/dist/`, `app/src-tauri/gen/` as Dropbox-ignored (Windows: `Set-Content -Path <dir> -Stream com.dropbox.ignored -Value 1`) — done by `scripts/dev-setup.ps1` / `.sh`, re-runnable.
3. Top-level layout:

   ```
   README.md  LICENSE (all rights reserved placeholder)  CONTRIBUTING.md (contributor assignment note, PR checklist reference)
   CLAUDE.md  .editorconfig  .gitignore  .gitattributes  rust-toolchain.toml (1.93)  Cargo.toml (workspace)
   docs/  FEATURE_PLAN.md (the owner's plan, verbatim, brand words redacted by lint)  DESIGN.md  ARCHITECTURE.md
          brand-blocklist.txt  theme-token-allowlist.json  THIRD_PARTY.md  EARLY_TESTS.md  adr/0001-stack.md …
   app/   src-tauri/ (Tauri app: kouch-app crate)   ui/ (Svelte)
   crates/ kouch-core kouch-steam kouch-input kouch-dsu kouch-vpad kouch-profiles kouch-launch kouch-library
   tools/  kouch-lab (Early Tests harness)
   profiles/ (committed local test profiles + example.generic.json)   schemas/ (generated JSON schemas)
   companion/  decky/  mobile/   (README placeholders only; not built in Phase 1–2)
   scripts/  dev-setup.ps1 dev-setup.sh  gen-sounds.mjs
   .github/workflows/ci.yml (matrix: windows-latest, ubuntu-22.04 → cargo fmt/clippy/test, npm design-lint/brand-lint/test, tauri build)
   ```
4. `docs/brand-blocklist.txt` — **I draft it** (console names, controller product names, character/franchise names, emulator names for excluded platforms, common abbreviations; one term per line, `#` comments, `*` prefix wildcard). Owner reviews before `brand-lint` is made blocking in CI (it runs in warn mode until then). Deliberate exception list inside the file for Steamworks enum identifiers that must be quoted verbatim (e.g. `k_ESteamInputType_SwitchProController`), matched only inside `crates/kouch-steam/`.
5. `CLAUDE.md` records the ground rules (no brand names in code/comments, no ROM/BIOS handling, DSU is the output path, design lint is blocking) so future sessions inherit them.
6. `steam_appid.txt` = `480` is generated into the dev build dir by `kouch-app`'s `build.rs`, never committed to `app/` release assets.
7. GitHub (via `gh`, owner approved): issue labels (`phase-1`, `phase-2`, `early-test`, `design`), one issue per milestone below, 2FA reminder in CONTRIBUTING. **Workflow = commit straight to `main`, no PRs** (owner's choice); CI runs on push but is not blocking; **no branch protection**. Friends get write access. Conventional-commit messages (`feat(input): …`) so devlog changelogs can be generated. The DESIGN.md §12 checklist therefore runs as a self-review + CI lint rather than a PR gate.
8. **Test emulator profiles live in a committed `profiles/` folder** (owner's choice while the repo is private). `brand-lint` **excludes `profiles/`**, and `docs/GO_PUBLIC_CHECKLIST.md` lists "scrub or git-filter `profiles/` before flipping the repo public". The repo also ships `profiles/example.generic.json` + `schemas/emulator-profile.schema.json` as the documented template.

## Backend (Rust) plan — Phase 1 + Rust half of Phase 2

Sizes: S ≤ 2 dev-days, M 3–6, L 7–12 (solo). Crate APIs named below were checked on docs.rs by the architect (steamworks 0.13.1 `Client::init_app`/`register_callback`, `SteamAPI_SteamInput_v006`, evdev 0.13.2 builder, tauri 2.11.5 `gtk_window`/`set_focusable`, tao X11 `RawWindowHandle::Xlib`, tauri-plugin-single-instance 2.4.4).

### Workspace

```
Cargo.toml                 [workspace] members = ["crates/*", "app/src-tauri", "tools/*"], resolver 2
rust-toolchain.toml        1.93 (installed)
crates/
  kouch-core      S  POD types only: PadState, Buttons(bitflags u32), MotionSample, ControllerKind, SlotOutput,
                     SlotStatus, ControllerSnapshot, RoutingTable, OnDisconnect, Feedback, InputEvent
  kouch-steam     S  init under app id; owns steamworks::Client + raw *mut ISteamInput (SteamAPI_SteamInput_v006);
                     safe fns for what the wrapper lacks (trigger_vibration_extended, set_led_color, gamepad_index,
                     session_config_mask, glyph_svg/png); Callback impls for the 4 SteamInput callbacks. All `unsafe` lives here.
  kouch-input     M  the input thread (RunFrame → read → route → publish → DSU send → feedback drain), SlotManager,
                     identity heuristics, claim flow, combo + nav decode
  kouch-dsu       M  DsuHub (4 servers), codec, ClientRegistry, DsuClient (for tests)
  kouch-vpad      S  trait VirtualPad + NullBackend; Linux uinput behind feature "uinput" (stretch)
  kouch-profiles  S  EmulatorProfile / Settings (serde + schemars; players.json is kouch-input's own PlayersFile), validation, paths via `dirs`
  kouch-launch    L  spawn (Job Object / pgid / HostExec), window discovery, hotkey injection, pause/suspend, quit
  kouch-library   M  scan + SQLite (rusqlite bundled) + hash on demand
app/src-tauri      M  crate kouch-app: Tauri state/commands/events, overlay plumbing, single-instance + --launch
  steam/kouch_actions.vdf + steam/controller_config/{ps4,ps5,xboxone,switch_pro,switch_joycon_left,_right,_pair,neptune,generic}.vdf
tools/kouch-lab    M  Early Tests harness (clap)
schemas/           generated by `kouch-lab gen-schemas`
```

Dependency direction: `kouch-input → kouch-dsu` (DSU send happens on the input thread), both → `kouch-core`. `kouch-core` exists to break the cycle.

### Input sources (decided 2026-09-17 from Early Tests 0–1)

Two facts measured on the owner's hardware shape the reader:

1. Steam only activates Steam Input for a process that **owns a real top-level window and was launched by Steam** under a Steam-Input-flagged app id. (Kouch's Tauri window + its own app id satisfy this; dev builds use the "Kouch Lab" non-Steam shortcut.)
2. When a **child window takes focus** (the emulator), Steam Input **actions go inactive** for as long as the child is up — but **`GetMotionData` keeps flowing at full rate**.
3. (Early Test 1b, 2026-09-26) Steam hands the processes it launches `SDL_GAMECONTROLLER_IGNORE_DEVICES`, a list covering nearly every pad. Honoured, SDL sees **no pads at all** in a Steam-launched Kouch. `OsSource` overrides it, so SDL reads every pad, Valve's own included, whatever window has focus (ADR 0005 addendum).

So `kouch-input` has two `InputSource`s from Phase 1, merged per controller:

| Channel                                                      | Source                                                                | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Motion (accel/gyro/quat)                                     | `SteamSource` — `GetMotionData` on the input thread                   | Works regardless of focus. The only source of gyro for Steam Controller / Deck-class pads.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Motion fallback (accel/gyro)                                 | `OsSource` — SDL3 gamepad sensors (added 2026-09-26)                  | Used only when a pad has no Steam motion, or Steam's has repeated the exact same sample for over 200 ms (`identity::SteamMotionWatch`). Covers no-Steam runs, pads Steam doesn't expose, and Linux outside Steam. Converted to g and °/s **and into the Steam Input frame** (+X right, +Y away from the player, +Z up out of the face), so every `MotionSample` has one frame whatever its source. SDL3's own frame is +X right, +Y up out of the face, +Z toward the player: **measured 2026-09-26** on two resting pads read through both at once, Steam = (x, −z, y) of SDL, a 90° rotation about X applied to accel and gyro alike (`source/os.rs::sdl_to_steam_frame`; before this, an SDL fallback showed a flat pad to the emulator as lying on its back). The keyboard seat's mouse-as-motion uses the same frame. `MotionAxisMap` still corrects per kind. |
| Buttons, sticks, triggers                                    | `OsSource` — SDL3 (`sdl3` crate) gamepad API                          | The same OS layer emulators launched by ES-DE-style frontends read (Steam's XInput emulation / DirectInput / HID). Survives child focus because it is not Steam Input. Under Steam this needs Steam's SDL ignore list overridden (`sdl_should_see_all`), or SDL sees nothing. Sticks pass a 0.07 radial rest dead zone.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Buttons, sticks, triggers (Steam Controller, Deck built-ins) | `SteamSource` actions while Kouch is foreground; `OsSource` otherwise | Since 2026-09-26 SDL reads these pads too (`28de:1304` / `1205`, paired with Steam's view through `identity::pair_family`), so they keep working with an emulator focused (Early Test 1b).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| LED, rumble, haptics                                         | `SteamSource`                                                         | Feedback channel unchanged.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

Identity join: SDL3 exposes a joystick GUID and (for HID pads) a serial; Steam Input exposes `InputHandle_t` and the device kind. `SlotManager` correlates the two by kind + connect-order + (when both sides expose it) USB path/serial, and treats "same kind, same ordinal" as the same device — good enough to attach Steam motion to the right OS pad in the common case. Mismatch shows as a motion badge on the wrong slot and is fixable from the claim screen; the heuristics improve in Phase 8 with hidapi.

Double-input: because the OS layer is also what the emulator would read, profiles default to `native_gamepads: disable_in_emulator_config` so the emulator only sees Kouch's DSU/virtual slots. The SDL3 reader opens pads non-exclusively, so it does not block the emulator if a profile leaves native pads on.

Cost: `sdl3` 0.20 (bundled SDL3 build via its `build-from-source` feature; \~1 MB) in `kouch-input` from Phase 1 instead of Phase 8. The SDL event pump runs on the input thread alongside `RunFrame`.

### Threading & latency

* **Input thread** (`std::thread` "kouch-input", Windows `THREAD_PRIORITY_HIGHEST`): owns `SteamCtx` (init here: `Client::init_app(480)`, `set_input_action_manifest_file_path` **before** `Input::init(true)`, `EnableDeviceCallbacks`). Tick = `run_callbacks()` → `run_frame()` → read (skip when `!BNewDataAvailable()` and no motion slot active; motion has no "new" flag so read every tick when any motion flag is set) → route via one `routing.load()` → publish → DSU send → drain feedback.
* **Rate: 500 Hz** (`poll_hz ∈ {250,500,1000}`), `spin_sleep` hybrid sleeper (Windows timer is 15.6 ms otherwise). Steam only exposes the latest packet, so faster than the device is waste; 500 Hz caps sampling jitter at 2 ms for \~5 % of one Deck core with 16 pads.
* **DSU send cadence:** per slot per client, on change, with a 100 Hz keep-alive floor and tick rate ceiling.
* **Publish:** `Arc<ArcSwap<ControllerSnapshot>>` + `tokio::sync::watch<u64>` seq (wait-free multi-reader; `triple_buffer` is SPSC and we have ≥3 consumers).
* **Feedback channel** (`crossbeam_channel`, bounded 256) drained on the input thread so every ISteamInput call stays on one thread: `Rumble{handle,l,r,lt,rt,ms}`, `Led{handle,rgb}`, `ClaimPulse{handle}`.
* **Atomic reorder:** UI never mutates routing in place; `SlotManager` builds a new `RoutingTable` and `routing.store(Arc::new(t))`; the thread routes all 16 slots from one loaded table per tick, so a swap lands whole on the next tick. Player `p` → DSU server `p/4`, slot `p%4` is fixed (profile-overridable), so the emulator's port mapping never moves.
* **Tokio runtime** (2 workers): 4 DSU rx tasks, launcher wait, library scan (blocking pool), 60 Hz UI emitter.

```rust
// kouch-core
bitflags! { pub struct Buttons: u32 { A,B,X,Y,LB,RB,LS,RS,START,SELECT,DPAD_UP,DPAD_DOWN,DPAD_LEFT,DPAD_RIGHT,MISC,TOUCH_CLICK,PADDLE1..4 } }
pub struct PadState { buttons: Buttons, lx, ly, rx, ry: f32 /* -1..1, +y up */, lt, rt: f32 /* 0..1 */ }
pub struct MotionSample { accel_g: [f32;3], gyro_dps: [f32;3], quat: [f32;4], ts_us: u64 }
pub enum ControllerKind { Unknown, SteamController, Xbox360, XboxOne, Generic, Ps4, Ps5, MobileTouch, Ps3, MotionPro /* maps k_ESteamInputType_SwitchProController; name chosen to keep brand terms out of Kouch identifiers */, SplitPair, SteamDeck }
pub struct ControllerFrame { handle: u64, kind: ControllerKind, gamepad_index: Option<u8>, pad: PadState, motion: Option<MotionSample> }
pub enum SlotStatus { Live, Neutral, Disconnected }
pub struct SlotOutput { source: Option<u64>, status: SlotStatus, motion_enabled: bool, pad: PadState, motion: MotionSample }
pub struct ControllerSnapshot { seq: u64, t_frame: Instant, t_us: u64, devices: Vec<ControllerFrame>, slots: [SlotOutput;16], frozen: bool }
pub struct RoutingTable { slot_source: [Option<u64>;16], motion_flags: u16 /* ≤8 bits */, on_disconnect: OnDisconnect, freeze: bool, reserved: u16 }
```

### Action manifest (`app/src-tauri/steam/kouch_actions.vdf`, bundled resource)

One set `"Kouch"`: `Button` = every face/shoulder/stick-click/start/select/dpad×4/misc/touch-click/paddle×4; `StickPadGyro` = `stick_left`, `stick_right` (`joystick_move`); `AnalogTrigger` = `trig_left`, `trig_right`. D-pad as 4 buttons (maps 1:1 to DSU bits). **Guide/Home is Steam's** → DSU HOME byte always 0; Quick Menu chord = Select+Start. Gyro is read via `GetMotionData`; per-type default configs must leave Steam's gyro mode off (whether motion still flows via API then is part of Early Test #0). Per-type `.vdf` defaults: bind in Big Picture under app 480 → export local binding → copy into `steam/controller_config/` → reference from `"configurations"` (relative paths UNVERIFIED → `kouch-lab manifest-check` confirms via `SteamInputConfigurationLoaded_t.uses_steam_input_api && mapping_creator == 0`). Startup: `GetSessionInputConfigurationSettings()` mask (PS=1, Xbox=2, Generic=4, MotionPro-class=8) → `InputEvent::SupportDisabled{kind}` banner if a connected kind's bit is off.

### Identity & slot persistence

Steam exposes no serial → best-effort `DeviceIdentity { kind, ordinal, hw }`, where `hw` is what SDL3 reports (serial, path, GUID). Heuristics: exact match → same-kind lowest Reserved slot on reconnect → ≥2 candidates: assign + `VerifyPrompt` (LED = slot color, double rumble, "Is this P3?") → else Free/claim screen. **Exact** means the same kind and the same serial; without serials, the same GUID *and* the same device path (SDL's GUID names the model, so identical pads share it; an XInput pad's `XInput#n` path is its connect-order index and does not count). The same-kind fallback never takes a Reserved seat whose pad reported a different serial. Steam and SDL views of one pad pair in connect order (first unpaired of the kind) and stay paired until one side leaves; the other view then waits unpaired for its own pad. A seat freed by `reserved_ttl_s` or release gives its motion flag back. Scripted end to end in `crates/kouch-input/src/scenarios.rs` (Early Tests 2, 5, 6, 15).

State machine per slot: `Free →(A press on claim screen, 400 ms debounce)→ Claiming →(LED + 2×100 ms rumble)→ Assigned{handle, identity} →(disconnect)→ Reserved{identity, since} →(match ≤ ttl)→ Assigned | →(ttl | release)→ Free`. `reserved_ttl_s` default `null` = whole session. **First seat** (2026-09-26): while nobody holds a seat and the claim screen is closed, the first unseated pad to press A/B/X/Y/Start takes the lowest free seat, console-style, so a couch PC or a Deck in Game Mode with no keyboard can always start navigating; exactly one pad, the keyboard seat and LAN guests excluded, and the press is swallowed by `NavDecoder` like any seat-starting press. All 16 slots always exist in `RoutingTable`. Motion: ≤ 8 flags; default = lowest 8 assigned motion-capable slots (`Ps4|Ps5|MotionPro|SplitPair|SteamDeck|SteamController`); flag stays on a slot when a non-motion device lands there (DSU model drops to "no gyro").

`players.json` (config dir): `{ version: 1, slots: [{ player, identity:{kind, ordinal}, motion, label, last_seen }] }`, saved debounced 500 ms. UI events (`input:event`): `DeviceConnected/Disconnected`, `SlotChanged{player, state}`, `ClaimPrompt`, `VerifyPrompt`, `MotionFlagsChanged`, `SupportDisabled`, `ComboTriggered`, `Nav{player, action}` (only while frozen).

### Virtual pads (rumble path B + C)

`trait VirtualPad { kind(); set_state(&PadState) -> Result; poll_rumble() -> Option<Rumble{large:u8, small:u8}> }`, `NullBackend`, `open(kind, index) -> Box<dyn VirtualPad>`.

* **Linux `UinputPad` (Milestone 10, M):** `evdev::uinput::VirtualDevice::builder().name("Kouch Pad N").input_id(BUS_USB, 0x045e, 0x028e, 0x0110)` — Xbox-360-like descriptor so SDL2/SDL3 auto-map it (BTN\_SOUTH..BTN\_MODE, ABS\_X/Y/RX/RY/Z/RZ/HAT0X/HAT0Y), `.with_ff(FF_RUMBLE)`, `.with_ff_effects_max(16)`. A small tokio task per pad drains `fetch_events()` → `process_ff_upload/erase` → `EV_FF` play → `Rumble` into the feedback channel → `TriggerVibration(handle)` on the input thread. Requires `/dev/uinput` (SteamOS `uaccess` OK; other distros documented).
* **Windows `VigemPad` (opt-in, M):** `vigem-client` 0.1.4 `Xbox360Wired` targets (the crate's second, touchpad-class target only if its unstable feature proves stable); rumble via the client's notification callback. Enabled only when a profile sets `players[].vpad`; on first use Kouch checks for the ViGEmBus driver and, if absent, opens the installer download page with a warning that the project is archived. Never auto-installs.
* **Coexistence with DSU:** a player may be bound to both a DSU slot (motion) and a vpad (buttons + rumble) — full-DSU-class emulators use DSU for everything; PCSX2-class use the vpad; PPSSPP-class use vpad for buttons + DSU for motion. Profile validation warns when both carry buttons (double input) unless `native_gamepads` policy covers it.
* **Rumble path A (DSU extension, S, in `kouch-dsu`):** rx task also parses `0x110002` rumble requests (slot, small/large motors per the unofficial spec); `0x110001` info reply advertises motor count per slot; a 5 s silence zeroes the motors. Forwarded as `Feedback::Rumble` for the slot's source handle by `kouch_input::dsu_rumble`: the request belongs to the *seat*, so after a reorder the pad now in the seat rumbles and the pad that left is stopped; an empty seat rumbles nobody; the Quick Menu freeze silences every pad until it closes; a running rumble is re-sent every second (pads run each command 1.5 s). On in the app since 2026-09-26.

### DSU servers (the only output path)

* `DsuHub::bind(cfg)` → 4 `DsuServer`s on `bind_addr:base_port+i` (default `127.0.0.1:26760..26763`). Each: `Arc<UdpSocket>`; rx tokio task parses `DSUC`/1001/CRC and updates `ClientRegistry` (`Mutex<HashMap<SocketAddr, ClientSub{last_seen, packet_no, mode: All|Slots(mask)|Macs}>>`, evict > 5 s); tx from the input thread via `try_send_to` (no thread hop).
* Fixed synthetic MAC per player `02:4B:4F:55:43:pp` (stable across sessions for MAC-keyed clients).
* Layout per verified spec; face buttons mapped by **position** (Steam A/B/X/Y → DSU Cross/Circle/Square/Triangle), Select→Share, Start→Options, LB/RB→L1/R1, LS/RS→L3/R3; sticks `128 + round(v*127)`; L2/R2 analog = trigger×255; one `u32` packet\_no per client.
* **Slot policy table:** Assigned+live → state 2, model 2 (motion flag ∧ capable) else 1, connected 1, real data. Free / Reserved + `hold_neutral` (default) / overlay `freeze` → state 2, connected 1, buttons 0, sticks 128, accel held, gyro 0, ts frozen. Reserved + `report_disconnected` → state 0, connected 0, zeros. Always full 100-byte packets.
* Units: `accel_g = v*2/32767`, `gyro_dps = v*2000/32767`. **Axes (verified in-game 2026-09-26):** every source hands the router the Steam Input frame (+X right, +Y away from the player, +Z up; flat reads (0,0,1); gyro X pitch, Y roll, Z yaw). The wire uses the frame of the touchpad-class pad the protocol came from: SDL's axes (+X right, +Y up, +Z toward the player) with accel negated (flat reads (0,−1,0)), gyro in pitch/**yaw**/roll order with pitch up, yaw right and roll right positive. `MotionAxisMap::STEAM_TO_WIRE` (the default) does the conversion: accel = (−x, −z, y), gyro = (x, −z, y). Derived by equating a reference emulator's SDL and DSU bindings for the same named motions, then checked with the dev virtual controller in a pointer game: centred at rest, left turn → left, nose up → up. Sending the Steam frame raw had put "flat" on the forward axis and swapped yaw with roll. `settings.advanced.motion_axis_map` stays unused (a per-kind correction hook). Timestamp advances only when accel changes (spec).
* Dev CLIs: `kouch-lab dsu-client --host --port --slots` (decode, CRC, rate, monotonic packet\_no; library `kouch_dsu::DsuClient` so tests need no emulator) and `dsu-probe` (hexdump).

### Launcher (`kouch-launch`)

* Spawn: `tokio::process::Command`, templated args (`{rom}`, `{rom_dir}`, `{rom_stem}`, `{profile_dir}`). Windows: Job Object (`KILL_ON_JOB_CLOSE | BREAKAWAY_OK`). Linux: `process_group(0)`.
* SLR/Flatpak escape (Linux): `trait HostExec { spawn; signal }` with strategy chain (1) Flatpak portal D-Bus `org.freedesktop.Flatpak.Development.HostCommand`/`HostCommandSignal` via `zbus` (UNVERIFIED reachable from SLR), (2) `steam-runtime-launch-client --alongside-steam --host` (UNVERIFIED), (3) direct. Detect SLR via `/run/pressure-vessel`. → `kouch-lab slr-escape`.
* Window discovery (30 s, 100 ms poll): Windows `EnumWindows` filtered by PID ∪ descendants, visible, largest DWM extended bounds; X11 (`x11rb`) `_NET_CLIENT_LIST` × `_NET_WM_PID`; Wayland desktop → `None` (overlay covers primary monitor; hotkeys still work via uinput).
* Hotkeys: Windows `SendInput` with scancodes (raw-input emulators ignore VK-only); Linux uinput virtual keyboard (works on X11/Wayland/gamescope alike).
* Pause: `PauseMethod::Hotkey(chord)` preferred; `Suspend` = `NtSuspendProcess` on every PID in the job / `killpg(SIGSTOP)` / `HostCommandSignal(to_pgroup)`.
* Quit: `QuitPolicy{ method: Hotkey|Close|Signal, force_kill_after_ms: 5000 }` → `WM_CLOSE`/`SIGTERM` → wait → `TerminateJobObject`/`SIGKILL`/`flatpak kill`. `GameSession::on_exit` closure = save-sync hook (Phase 6).
* **Linux in-game features (built 2026-09-26, C):**
  * Discovery, focus, borderless and close live in `linux_x11.rs` (pure-Rust `x11rb`). The key-code mapping, process-tree match and window pick are in `linux_pure.rs`, tested on every OS.
  * **Discovery:** the `_NET_CLIENT_LIST` (or a two-level root-tree walk when no WM keeps one, as under gamescope) is matched against the session process group or its descendants. A window's pid comes from the X server first (X-Resource `QueryClientIds`, the host pid, as gamescope does), else `_NET_WM_PID`. A Flatpak app writes its sandbox-internal pid into `_NET_WM_PID`; the X-Resource pid walks `flatpak run` → `bwrap` correctly. The largest viewable window of at least 64×64 wins.
  * **Focus:** `_NET_ACTIVE_WINDOW` (source 2) plus a raise. **Borderless:** `_NET_WM_STATE_FULLSCREEN` on a desktop, a no-op under gamescope. **Quit:** the quit hotkey or `_NET_CLOSE_WINDOW` first, then after half the grace period `SIGTERM` → `SIGKILL`.
  * **Hotkeys:** a per-session uinput keyboard (`linux_uinput.rs`, evdev), created at spawn, with the same 20 ms stages as Windows. Without `/dev/uinput` access it gives a readable error.
  * **Verified in the Linux container under Xvfb:** a real window found by process tree; focus, fullscreen and close accepted.
  * **Unverified without a Deck:**
    1. Whether a raise brings the emulator in front of Kouch under gamescope (both carry Kouch's app id; gamescope picks by focused app).
    2. Whether `deck` can open `/dev/uinput` through Steam's `uaccess` rule.
    3. Whether gamescope keeps no `_NET_CLIENT_LIST`.
* Steam handoff: `tauri_plugin_single_instance` registered first; `--launch=<game-id>` → resolve rom+profile → spawn → return to library.

### Overlay plumbing (`kouch-app/src/overlay/`)

Created hidden at startup: `transparent(true).decorations(false).shadow(false).always_on_top(true).skip_taskbar(true).visible(false).focused(false)` (resizable, so `set_size` to the emulator rect works — measured 2026-09-18: a non-resizable webview window ignores `set_size` on Windows) + `set_focusable(false)` + `set_ignore_cursor_events(true)`. `OverlayCtl::show(rect)` = position/size to emulator rect → cursor events on → `show()` → new `RoutingTable{freeze:true}` → pause per profile; `hide()` reverses. Non-focusable because nav comes from the input thread's `Nav` events, so there is no focus dance. Linux X11: xid via `raw_window_handle` (`RawWindowHandle::Xlib`), set `_NET_WM_WINDOW_TYPE_UTILITY`; gamescope: a same-app override-redirect panel with pinned size hints (see "gamescope overlay" above; verified in Game Mode 2026-09-26). Quick Menu commands: `qm_open/close/swap/set_order/set_motion/action(SaveState|LoadState|Reset|Screenshot)/quit_game/toggle_pause`. Combo (Select+Start held `combo_hold_ms=1000` on any Assigned controller) detected on the input thread.

### Config & data

| Path   | Windows                | Linux                  | Content                                             |
| ------ | ---------------------- | ---------------------- | --------------------------------------------------- |
| config | `%APPDATA%\kouch`      | `~/.config/kouch`      | `settings.json`, `players.json`, `emulators/*.json` |
| data   | `%LOCALAPPDATA%\kouch` | `~/.local/share/kouch` | `library.db`, `cache/glyphs/`                       |
| logs   | `<data>/logs`          |                        | `tracing-appender` daily, keep 7                    |

`library.db` schema: `systems(id, extensions)`, `scan_roots(id, path, system_id)`, `roms(id, system_id, path UNIQUE, size, mtime, title_guess, sha1?, crc32?, play_count, last_played)`, `schema_version`.

```rust
pub struct EmulatorProfile { version: u32, id, name: String, launch: LaunchTarget /* Exe{path} | Flatpak{app_id, extra_args} */,
  args: Vec<String>, working_dir: Option<PathBuf>, env: BTreeMap<String,String>, systems: Vec<String>, max_players: u8,
  players: Vec<PlayerBinding> /* default p → dsu{server:p/4, slot:p%4} */, window_mode: WindowMode /* Borderless default */,
  hotkeys: Hotkeys /* pause, save_state, load_state, reset, screenshot, quit: Option<KeyChord> */, pause: PauseMethod,
  quit: QuitPolicy, on_disconnect: OnDisconnect /* HoldNeutral default */, dsu: Option<DsuOverride>,
  native_gamepads: NativeGamepadPolicy /* DisableInEmulatorConfig (default) | LeaveAlone */ }
pub struct PlayerBinding { dsu: Option<DsuBinding{server:0..3, slot:0..3}>, vpad: Option<VpadBinding>, motion: bool }
```

Validation: unique `(server,slot)`, `popcount(motion) ≤ 8`, arg templates resolvable, exe exists (warning). `settings.json`: `poll_hz`, `dsu{bind_addr, base_port}`, `combo{hold_ms}`, `reserved_ttl_s`, `max_motion_slots`, `motion_axis_map`, `library.roots[]`, `log_filter`, `accent{use_os, override}`, `ui_sounds`, `confirm_destructive`, `user_data_root: Option<PathBuf>`, `cloud_cap_mb: u32` (default 200), `lan: LanSettings`.

#### Emulator user data, Steam Cloud, LAN transfer/play (`docs/DESIGN.md` §10c.1, ADR 0007)

`EmulatorProfile.user_data: UserData` (`crates/kouch-profiles/src/userdata.rs`) — `folders: BTreeMap<String, String>` (defaults to `saves`/`states`/`screenshots`/`config`/`system`, each named after its own key), `cloud: Vec<String>` (never `"system"`, validated), `config_edits: Vec<ConfigEdit>` (`{file, format: ini|json|toml|text, set}`, applied idempotently by `crates/kouch-profiles/src/config_edit.rs` before every launch — `<file>.kouch-backup` taken once on first touch, `<file>.kouch-managed` marks that first touch so a file `apply_config_edits` itself created isn't mistaken for pre-existing on the next call). `EmulatorProfile.cloud_sync: bool` (default true since ADR 0018; imports keep it on) is the per-profile Steam Cloud switch. `Settings.user_data_root` (default `<data>/userdata`) is the whole data folder's relocatable root; `crate::userdata::UserDataContext` (both `kouch-launch` and `app/src-tauri`) resolves it against a profile's `id`/`launch.path` into absolute paths and the extra template placeholders `{user_data}`, `{emulator_dir}`, and every `folders` key (`{saves}`, `{states}`, …) — layered on top of the original four (`{rom}`/`{rom_dir}`/`{rom_stem}`/`{profile_dir}`) via `template::resolve_template_with_extra`.

**Steam Cloud** (`app/src-tauri/src/cloud.rs`) file layout: `<profile id>/<folder key>/<relative path>` (e.g. `my-emulator/saves/slot1.srm`) via `ISteamRemoteStorage::FileWrite`/`FileRead`/`GetFileTimestamp`. Uploads on session-idle (game exit) and `cloud_sync_now`; downloads anything with a newer cloud timestamp than the local file's mtime before every launch and once at Kouch startup. A file over Steam's 100 MB per-write cap is skipped with a `tracing::warn!`; a profile's running total over `settings.cloud_cap_mb` stops the upload early and raises a toast. **Borrowed app id guard:** `crate::input::is_borrowed_app_id()` (true whenever `KOUCH_APP_ID` names an app `kouch_steam::borrowed::lookup` resolves) short-circuits every sync to a no-op `done` — writing Steam Cloud files under a borrowed id would land them in *that other game's* cloud slot on the owner's Steam account. The live write path is therefore unverified until Kouch has its own app id; `crates/kouch-lan`/`cloud.rs`'s own unit tests cover everything that doesn't require a real signed-in Steam session.

Steam API access off the input thread: `kouch_input::InputSource::steam_client() -> Option<steamworks::Client>` (default `None`; `SteamSource` returns `self.ctx.client().clone()`) is published once, at input-thread startup, to `InputHandle::steam_client: Arc<ArcSwap<Option<steamworks::Client>>>` — `steamworks::Client` is `Send + Sync` (asserted by the vendored crate itself in `Client::init`), unlike `kouch_steam::SteamCtx` (deliberately `!Send`, ground rule 4: every `ISteamInput` call stays on the input thread) or `steamworks::RemoteStorage` (holds raw pointers, never sent across threads — `cloud.rs` builds one fresh, per call, from the cloned `Client`, inside `tauri::async_runtime::spawn_blocking`).

**LAN transfer and LAN play** (`crates/kouch-lan`, `app/src-tauri/src/lan.rs`) — a self-contained, `tokio`-based crate, deliberately outside `kouch-input` so the input thread never touches a socket:

* **Discovery** (`kouch_lan::discovery`): mDNS/DNS-SD `_kouch._tcp.local.` via `mdns-sd`; TXT `id`/`name`/`proto`/`role` (`idle`|`host`). `is_lan_address` accepts only RFC1918/link-local/IPv6 ULA, rejecting loopback and public addresses on both the advertise and the browse side. Runs only while `settings.lan.enabled` and `lan_start_discovery` has been called (the Transfer screen open, or a transfer/session in progress).
* **Pairing** (`kouch_lan::crypto`/`transfer`): a 6-digit code (`generate_code`), symmetric SPAKE2 (`spake2::Spake2<Ed25519Group>`) deriving a raw secret, HKDF-SHA256 into a 32-byte key, then a plaintext `PairConfirm` tag exchange (`SHA-256(key ‖ "confirm")`) *before* the channel switches to encrypted mode — so a wrong code fails as a clear `ConfirmMismatch`, never an opaque AEAD decrypt error three steps later. The derived key is persisted as `kouch_profiles::PairedPeer{id, name, key_fingerprint, key_b64}` in `settings.lan.paired`; `key_b64` is redacted (blanked) before `Settings` ever crosses `settings_get`/`settings_set`'s IPC boundary to the webview — the frontend's `LanPairedPeer` type has no such field.
* **Transport** (`kouch_lan::protocol::Channel`): one TCP connection, `u32`-LE length-prefixed frames, plaintext before the key is set and ChaCha20-Poly1305-encrypted after, with disjoint per-direction nonces (`role byte ‖ 3×0 ‖ big-endian counter`, `Role::Initiator`/`Role::Responder`) so both sides can share one key safely. File bytes use the same framing (`send_bytes`/`recv_bytes`), chunked at 256 KiB. `crates/kouch-lan/src/transfer.rs`'s own test suite exercises the whole handshake → `Offer`/`Accept` → `send_sections`/`receive_sections` round trip end to end over `tokio::io::duplex`, including the hard `"system"`-exclusion rule from two angles (a `FolderStart` claiming to be `system`, and an honest folder's file path smuggled as `system/…`) and a deliberately mismatched pairing code.
* **LAN play — Mode A, controller forwarding** (`kouch_lan::play`/`datagram`, `kouch_input::source::LanSource`): the TCP control channel carries `LanJoin`/`LanJoinAccepted`/`LanJoinRejected` and `LanClaim`/`LanSeatAssigned`/`LanSeatDenied`; a forwarded seat is keyed as an ordinary `SourceId::Os(kouch_input::source::seat_key(peer_index, guest_seat))` — a reserved high range of the `u32` space (`0xF000_0000 | peer_index<<8 | guest_seat`) that can never collide with a real SDL instance id — so it flows through `identity::merge_into`/`SlotManager` completely unchanged; only its `kind` (`ControllerKind::Remote`, a new brand-neutral fieldless variant) marks it as network-forwarded. Pad state travels guest → host as an encrypted UDP `Datagram{seq, frames}` at up to the input thread's own tick rate (250 Hz on a downsampled guest), `ReplayGuard` dropping anything not strictly newer; rumble rides the same socket back, host → guest, as `Datagram{seq, rumble}` (`RumbleCommand`), applied on the guest via the existing `InputHandle::send_feedback`. `app/src-tauri/src/lan.rs`'s host side reuses the *same* claim machinery a local player's claim already goes through (`crate::input::build_claim` + `InputCmd::SetOrder`), so a forwarded seat is never auto-claimed — it lands in an explicit, confirmed slot.
* **Unverified live:** mDNS across a real second machine, an actual paired transfer, and a real co-op session all need the owner's hardware; `crates/kouch-lan`'s protocol-level tests (38 covering crypto/discovery-address-filtering/manifest/protocol/transfer/play/datagram) are what stands in for that until then.

### Backend risks & fallbacks

| Risk                                                                   | Fallback                                                                                                                                                                                                 |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ISteamInput not functional under app 480                               | Early Test 0 first; buy Steam Direct slot; `trait InputSource` so SDL3/hidapi could substitute                                                                                                           |
| Steam stops delivering / leaks desktop config when the child has focus | Early Test 1; PARTIAL → ship per-type configs with desktop layer cleared + warn on `ConfigurationLoaded_t`; FAIL → pull hidapi source forward, or Windows-only `SetParent` last resort                   |
| WebKitGTK 4.1 absent inside SLR sniper                                 | Develop with compat tool = none; evaluate AppImage bundling before Linux release; `kouch-lab deck-webview`                                                                                               |
| Split-pair enums unused in SDK 1.64                                    | Treated as `MotionPro`; a pair = one player; singles unsupported until Phase 3 (HID)                                                                                                                     |
| gamescope overlay transparency                                         | Opaque panel fallback                                                                                                                                                                                    |
| SLR container launches                                                 | `HostExec` chain; last resort: shortcut compat tool = none                                                                                                                                               |
| 16 BT controllers per adapter                                          | Docs only (\~7 per adapter; use 2.4 GHz dongles / 2nd adapter)                                                                                                                                           |
| Motion axis signs                                                      | Resolved 2026-09-26: `MotionAxisMap::STEAM_TO_WIRE`, verified in-game                                                                                                                                    |
| `NtSuspendProcess` side effects                                        | Hotkey pause preferred when the profile has one                                                                                                                                                          |
| Windows virtual pads depend on archived ViGEmBus                       | Opt-in only; Kouch runs without it. If the last signed installer stops working on a future Windows build, fall back to DSU-only on Windows and re-evaluate (vJoy DirectInput, GameInput virtual devices) |
| DSU rumble extension unsupported by an emulator                        | Rumble for that emulator comes only via a virtual pad (B/C); documented per profile                                                                                                                      |

### Backend milestones (feed the global execution order below)

| #  | Milestone                                                                                                         | Size | Done when                                                                                                                                         |
| -- | ----------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| B0 | Workspace, `kouch-core`, `kouch-steam` init under 480, manifest v1, `kouch-lab steam-input-480`                   | S    | Early Test 0 PASS with ≥2 pads incl. one motion-capable                                                                                           |
| B1 | Input thread: 500 Hz, reads, motion, snapshot publish, callbacks, feedback                                        | M    | `kouch-lab latency` p99 < 0.5 ms in-process                                                                                                       |
| B2 | `kouch-dsu` 4 servers + codec + registry + `dsu-client`; per-type `.vdf` + `manifest-check`                       | M    | The reference emulator shows 16 controllers over 4 servers; motion on P1–P8; 0 CRC errors                                                         |
| B3 | `SlotManager`: state machine, claim (LED+rumble), `players.json`, atomic reorder, motion flags, combo/nav         | M    | `reconnect-order`, `dsu-reorder`, `dsu-disconnect` PASS                                                                                           |
| B4 | `focus-flow`, `suspend-resume`, `motion-axes`; decide fallbacks                                                   | S    | Reports filed; risks table updated                                                                                                                |
| B5 | `kouch-profiles` + `kouch-library`                                                                                | M    | schema generated; 10k-file incremental scan < 5 s                                                                                                 |
| B6 | `kouch-launch` incl. `slr-escape` on Deck                                                                         | L    | Launch from profile on both OSes; window found < 2 s; hotkey lands; graceful→force quit verified                                                  |
| B7 | `kouch-app`: state, commands, single-instance, overlay plumbing (Win + X11 + gamescope), Quick Menu orchestration | M    | Overlay opens over emulator; input frozen; reorder/quit from overlay                                                                              |
| B8 | Rumble: DSU extension (A) + Linux `UinputPad` with FF (B) + `vpad-16` + `rumble-roundtrip` lab test               | M    | A full-DSU-class emulator rumbles a DualSense via DSU; the reference emulator/PCSX2 on Deck rumble via uinput pads; 16 uinput pads visible to SDL |
| B9 | Windows opt-in `VigemPad` (C)                                                                                     | M    | Profile with `vpad` on Windows: PCSX2-class emulator sees 16 Xbox360 pads, rumble round-trips; Kouch still runs with the driver absent            |

***

## Execution order (what gets built, in sequence)

| #  | Milestone                                                                                                                                             | Size | Done when                                                                                                                                          |
| -- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0  | Repo foundation (above) + `docs/DESIGN.md` + `CLAUDE.md` + lint scripts + CI                                                                          | M    | CI green on an empty Tauri app + empty crates; Dropbox ignores set; `brand-lint` in warn mode                                                      |
| 1  | `kouch-steam` + `kouch-lab steam-input-480` + `focus-flow` (**Early Tests 0–1**)                                                                      | M    | Go/no-go recorded in `docs/EARLY_TESTS.md`; if no-go → owner buys Steam Direct slot, app ID swapped in                                             |
| 2  | `kouch-input`: input thread, `ControllerSnapshot`, slot manager, identity heuristics, `players.json`, motion-slot allocation                          | L    | `kouch-lab reconnect-order` passes; 16 handles → 16 slots in a terminal dump                                                                       |
| 3  | `kouch-dsu`: 4 servers, client registry, packet builder, unit conversion, `dsu-probe` + `dsu-client` CLIs                                             | M    | The reference emulator sees 16 slots, motion on 8; **Early Tests 4–7, 15**                                                                         |
| 4  | `kouch-profiles` + `kouch-launch`: schema, spawn, window discovery, hotkey injection, pause/suspend, graceful/force quit, single-instance `--launch=` | L    | A profile launches an emulator with 16 players over DSU; **Early Tests 11–13**                                                                     |
| 5  | `kouch-app` Tauri shell + UI steps 0–4 (scaffold, IPC + mock, focus manager, primitives, shell)                                                       | L    | Tile grid on mock data navigable by controller via `nav:input`; design-lint blocking                                                               |
| 6  | Claim-a-slot screen (UI step 7) wired to `kouch-input`                                                                                                | M    | LED + rumble confirm on claim; motion badges; reorder                                                                                              |
| 7  | `kouch-library` (SQLite) + Library grid + detail sheet + launch/quit flow (UI steps 5–6)                                                              | L    | Scan a folder → tiles → launch → return with focus restored                                                                                        |
| 8  | Settings + theme/accent pipeline + sounds (UI steps 8–9)                                                                                              | M    | OS accent read + gate; theme.json load; safe area; text scale                                                                                      |
| 9  | Quick Menu overlay: Rust window plumbing + UI step 10; **Early Tests 9–10** on Windows + Deck                                                         | L    | Chord opens panel over a running emulator; players list/reorder/motion; game actions; quit                                                         |
| 10 | Linux/Deck build + CI matrix + **Early Tests 8, 14**; **rumble A + B** (`kouch-dsu` extension, `UinputPad` with FF, `rumble-roundtrip` test)          | L    | AppImage/deb runs on Deck desktop + Game Mode; 16 pads stable 10 min; the reference emulator/PCSX2 on Deck rumble the physical pads                |
| 11 | **Windows opt-in `VigemPad` (rumble C)** + profile "virtual pads" toggle + driver-absent flow                                                         | M    | PCSX2-class emulator on Windows sees 16 pads with rumble; Kouch unaffected when driver missing                                                     |
| 12 | Phase 2 sign-off: perf pass (UI step 11), E2E smoke (UI step 12), docs/ADRs updated, `GO_PUBLIC_CHECKLIST.md`                                         | M    | "Library grid launches an emulator with 16 players' buttons over DSU, motion on 8, rumble round-trips, Quick Menu can reorder/quit" — on both OSes |

**First coding session = Milestones 0 + 1** (owner's choice): scaffold, design system, lint, CI, and the two go/no-go Steam Input tests on this PC.

## Brand blocklist (`docs/brand-blocklist.txt`) — drafting rule

I write the initial file from these categories; the owner reviews it before `brand-lint` flips from warn to blocking:

* The excluded platform holder's company name and all its console/handheld family names and abbreviations (home consoles, handhelds, hybrid, their codename-style abbreviations like the 2–4-letter fan shorthand).
* Its controller product names (the split-pair controllers, the pro-style pad, the motion remote, the tablet pad) and accessory names.
* Its major franchise/character names likely to show up in game file names used in screenshots or examples.
* Emulator names for the excluded platforms (the well-known ones for each generation), including forks.
* The "system menu"/"plaza" terms that would give away the visual reference.
* Format: one term per line, `#` comments, case-insensitive whole-word, trailing `*` = prefix match. Per-path allow rule: `allow: crates/kouch-steam/** k_ESteamInputType_*` so Steamworks enum identifiers may be quoted verbatim in the FFI layer only.
* Scope: `app/**`, `crates/**`, `docs/**`, `README.md`, `CLAUDE.md`, commit messages (via a `commit-msg` hook installed by `dev-setup`). Excludes `profiles/` (owner decision) and `FEATURE_PLAN.md` gets a redacted copy.

## Early Tests (go/no-go gates, run via `cargo run -p kouch-lab -- <test>`; results recorded in `docs/EARLY_TESTS.md`)

| #  | Test                                                                                                                                                                                    | Pass criteria                                                                                                       | If it fails                                                                                                                                                                                    |
| -- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0  | `steam-input-480` — `Init` + `SetInputActionManifestFilePath` + `RunFrame` under app 480                                                                                                | ≥1 handle from `GetConnectedControllers`, digital/analog actions and `GetMotionData` non-zero on a gyro pad         | Buy Steam Direct slot ($100) → real app ID; continue                                                                                                                                           |
| 1  | `focus-flow` — spawn a child window (notepad / any emulator), give it focus, keep polling                                                                                               | Action data + motion keep updating while the child has focus, for ≥ 60 s                                            | Kouch keeps a hidden always-focused-in-Steam's-eyes window; or run emulator as child of a Steam-launched helper; worst case: Windows only via `SteamInputConfigurationLoaded_t` focus tracking |
| 2  | `reconnect-order` — unplug/replug 3 pads                                                                                                                                                | Kouch slot map unchanged; Steam handle changes are absorbed by identity heuristics                                  | Tighten heuristics; add "re-claim" prompt                                                                                                                                                      |
| 3  | `steam-reorder` — use Steam's Reorder Controllers UI                                                                                                                                    | Document whether it changes anything Kouch sees; ship "Use Steam's order" toggle only if it does                    | Toggle hidden                                                                                                                                                                                  |
| 4  | `dsu-probe` / `dsu-client` — 4 servers, 16 slots                                                                                                                                        | The reference emulator sees 16 slots across 4 server entries, motion on 8; packets ≤ 2 ms after `RunFrame`          | Fix packet layout; check the reference emulator's per-server slot cap                                                                                                                          |
| 5  | `dsu-reorder` — swap P1↔P5 live in the reference emulator                                                                                                                               | Emulator port mapping unchanged; inputs swap instantly                                                              | Slot policy bug                                                                                                                                                                                |
| 6  | `dsu-disconnect` — unplug P3 mid-game                                                                                                                                                   | `hold_neutral`: slot keeps reporting connected + neutral; `report_disconnected`: slot flips to disconnected         | Policy bug                                                                                                                                                                                     |
| 7  | `latency` — timestamp `RunFrame` → DSU send                                                                                                                                             | p99 ≤ 2 ms on Deck at 250 Hz loop; CPU ≤ 5 % one core with 16 pads                                                  | Lower loop rate / batch sends                                                                                                                                                                  |
| 8  | `sixteen` — 16 pads (mix BT/USB/phones later)                                                                                                                                           | All 16 enumerated, no drops for 10 min                                                                              | Document adapter guidance; cap BT per adapter                                                                                                                                                  |
| 9  | `overlay-win` — overlay over borderless vs exclusive-fullscreen emulator                                                                                                                | Visible + click-through works over borderless; exclusive documented as unsupported (profiles default to borderless) | Force borderless in profiles                                                                                                                                                                   |
| 10 | `overlay-gamescope` — Deck Game Mode, Quick Menu over the running game (same-app override-redirect; the external-overlay atom was the first plan)                                       | Panel composited over the game; input reaches it                                                                    | Fall back to Kouch's own fullscreen window swap (hide game, show menu)                                                                                                                         |
| 11 | `pause-neutral` — open overlay per emulator                                                                                                                                             | Emulator receives neutral input; pause hotkey or suspend works; no stuck buttons on close                           | Per-profile tuning                                                                                                                                                                             |
| 12 | `quit-graceful` — WM\_CLOSE/SIGTERM then kill                                                                                                                                           | Save files intact after both paths (hash before/after)                                                              | Longer timeout / profile-specific quit hotkey                                                                                                                                                  |
| 13 | `shortcut-handoff` — non-Steam shortcut → `steam://run/480//--launch=<id>`                                                                                                              | Kouch receives the arg (single-instance), launches game, Steam overlay shows Kouch's session                        | Companion tool falls back to a stub `.exe` launcher                                                                                                                                            |
| 14 | `slr-flatpak` — from a Steam-launched Kouch on Deck, run `flatpak run <emu>`                                                                                                            | Emulator starts and gets DSU                                                                                        | Use `steam-runtime-launch-client --alongside-steam --host` / `flatpak-spawn --host`; document                                                                                                  |
| 15 | `motion-reassign` — move motion slot P2→P9 mid-game                                                                                                                                     | DSU slot for P9 starts carrying motion within one packet; P2's stops                                                | Mapping bug                                                                                                                                                                                    |
| 16 | `rumble-roundtrip` — (a) `dsu-client --rumble` sends `0x110002` to slot 0; (b) Linux: `fftest`-style FF effect on a `UinputPad`; (c) Windows opt-in: XInput `SetState` on the ViGEm pad | Physical controller on that slot vibrates ≤ 50 ms after the request, on all three paths                             | Extension parse bug / FF event drain / driver absent                                                                                                                                           |

## Verification (end-to-end, after each milestone)

* **Rust:** `cargo fmt --check && cargo clippy --workspace --all-targets -D warnings && cargo test --workspace` on Windows and Linux CI. `kouch-lab` subcommands are the integration tests for hardware-dependent behavior; each writes a JSON result to `docs/EARLY_TESTS.md` via `--record`.
* **UI:** `npm run design-lint && npm run brand-lint && npm test` (Vitest) in CI; WebdriverIO smoke on both OSes from Milestone 11. Manual: screenshots at 1280×800 / 1920×1080 / 3840×2160 + 150 % text scale attached to each UI PR (checklist item 12).
* **Phase 2 acceptance (manual, on this PC and on the Deck):** connect ≥ 8 controllers → claim screen shows them with LED colors → library scan of a test folder → launch the reference emulator's profile → all players' buttons move in the reference emulator's controller config UI, motion on the 8 motion slots → hold Select+Start → Quick Menu → swap two players → the reference emulator's mapping unchanged, inputs swapped → Quit → back on the launched tile with focus.
* **Design compliance:** the 18-item checklist in DESIGN.md §12 is a self-review template (no PR gate — commits go straight to `main`); CI enforces items 1, 2, 10 (via `contrast.mjs` on token pairs), 16 (bundle size) and reports failures on the commit.

## Frontend plan — Phase 2 (Svelte 5 + TypeScript + Vite in `app/ui/`)

**Framework:** Svelte 5 (runes, no virtual DOM, scoped CSS makes the design-lint reliable; `svelte/motion` `Spring`/`Tween` for the hold ring). Rules: no `svelte/transition` on the grid (class-toggled CSS transitions only), `{@html}` only for sanitized glyph SVG, no store libraries.

**Layout**

```
app/ui/
  index.html  overlay.html            # two Vite entries: main window + overlay window
  public/fonts/InterVariable.woff2 (+LICENSE.txt)   public/glyphs/fallback/*.svg
  scripts/design-lint.mjs  brand-lint.mjs  contrast.mjs
  src/
    ipc/      types.ts commands.ts events.ts mock.ts      # typed invoke/listen; mock transport for browser-only dev
    focus/    manager.svelte.ts spatial.ts groups.ts ownership.ts secondary-input.ts focusable.ts
    router/   stack.svelte.ts                              # screen stack + focus memory
    stores/   players|library|session|settings|theme|toasts .svelte.ts
    theme/    tokens.css reset.css fonts.css loader.ts allowlist.ts
    glyphs/   cache.ts Glyph.svelte fallback.ts
    i18n/     en.json t.ts
    lib/      cover.ts (fnv1a→hue) format.ts perf.ts
    components/ Tile TileGrid TilePage PageDots StatusStrip BottomBar Prompt PlayerChip MotionBadge
                Button HoldButton Sheet Modal ListRow Toggle Slider Toast Cover DebugHud
    screens/  Boot Claim Library GameDetail Quit Settings/{Index,Paths,Emulators,Players,Theme,Display}
              overlay/{QuickMenu,PlayersPanel,GameActions,ControllerTools}
  tests/ (Vitest)   e2e/ (WebdriverIO + @wdio/tauri-service)
docs/DESIGN.md  docs/brand-blocklist.txt  docs/theme-token-allowlist.json
```

**IPC contract** (TS API name → Rust command; wire payloads `snake_case`; events use `:`):

| TS API                                                             | Rust cmd                                           | Args → Returns                                                                                                                                   |
| ------------------------------------------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `library.scan` / `library.list` / `library.remove`                 | `library_scan` / `library_list` / `library_remove` | `{full?}` → `{job_id}` (+`library:progress`); `{sort?,system_id?,query?}` → `Game[]` (includes `missing` rows); `{game_id}` → `{ok,message?}`    |
| `game.launch` / `game.set_art`                                     | `game_launch` / `game_set_art`                     | `{game_id, profile_id?}` → `{ok,message?}`; then `session:state`                                                                                 |
| `players.list/reorder/set_motion_slot/set_navigator/claim/release` | same with `_`                                      | `{order: handle[]}` / `{player_id, motion_slot\|null}` / `{player_id, can_navigate}` / `{player_id, device_handle}` / `{player_id}` → `Player[]` |
| `overlay.show/hide`                                                | `overlay_show/hide`                                | Rust owns show/hide + ignore-cursor                                                                                                              |
| `emulator.action`                                                  | `emulator_action`                                  | `{action: save_state\|load_state\|reset\|screenshot, slot?}` → `{ok,message?}`                                                                   |
| `emulators.list/upsert/remove`                                     | `emulators_list/emulators_upsert/emulators_remove` | `{}` / `EmulatorProfile` (own fields as top-level args) / `{id}` → `EmulatorProfile[]`, backed by `kouch_profiles::ProfileStore`                 |
| `session.quit`                                                     | `session_quit`                                     | `{mode: graceful\|force}`                                                                                                                        |
| `theme.get/set/list`, `settings.get/set`, `glyph.get`              | …                                                  | `ThemeState` (incl. `rejected_os_accent`) / `ThemeSummary[]` / `Settings` (deep patch) / `{svg\|null, fallback_key}`                             |
| `controller.rumble_test/recalibrate_motion/disconnect`             | …                                                  | `{player_id}` → `{ok}`                                                                                                                           |

Events: `nav:input {player_id, device_type, action, phase: press|repeat|release|long, ts_ms}` (emitted to **either** main or overlay window, never both), `players:changed`, `session:state`, `emulator:state`, `toast`, `theme:changed {css, assets, accent}`, `library:progress`, `overlay:visibility`. Full TS types are in Appendix A §B3 of the design report (kept in `app/ui/src/ipc/types.ts`).

**Overlay window** (`tauri.conf.json`): `{label:"overlay", url:"overlay.html", transparent:true, alwaysOnTop:true, decorations:false, visible:false, focus:false, skipTaskbar:true, shadow:false, resizable:true (Rust resizes it to the game window)}`, sized to the left panel (34% × 100%, full width < 900 px). Rust: `set_ignore_cursor_events(true)` + `hide()` when hidden.

**Focus manager** (`focus/manager.svelte.ts`): explicit groups (`grid|row|column|free`) with `cols`, `wrap`, `exits`, `owner: host|any|PlayerId[]`; scopes with `trap` for sheets/modals; per-player cursors (host uses DOM focus, others use `data-focus-p="N"`); grid/row/column = O(1) index math, `free` = spatial search on cached rects (invalidated by ResizeObserver/page change, never measured mid-transition); per-screen focus memory restored on pop and after a game exits (focus the launched tile). Rust does repeat timing (350 ms initial, 80 ms, 50 ms after 1.5 s). Secondary input (keyboard/pointer) is converted to host `NavInput`.

**Screens:** Boot → Claim-a-slot (8×2 grid, per-player cursors, X toggles motion, host Continue) → Library (status strip / paged grid / bottom bar; A launch, long-A detail, LB/RB page, Start = Quick Menu) → Game detail sheet → Quit/hand-off; Settings (Paths, Emulators, Players, Theme, Display); Quick Menu overlay (Players, Game actions, Controller tools, reserved rows for Phone/Invite/Netplay, Quit). Empty + error states specified per screen in the design report.

**Perf budget:** nav→tick p95 ≤ 4 ms; page/tile transitions do zero layout mid-frame; JS ≤ 250 KB gzip; ≤ 30 tiles mounted; RSS ≤ 150 MB (WebView2) / 120 MB (WebKitGTK) measured from Rust.

**Lint/CI:** `design-lint.mjs` fails on raw colors outside `tokens.css`, `gradient(`, `box-shadow`/`text-shadow`/`drop-shadow`/`backdrop-filter`, `:hover`, non-transform/opacity transitions, `font-family` outside `fonts.css`; `brand-lint.mjs` against `docs/brand-blocklist.txt` + `gyro` in `en.json`; `contrast.mjs` helper; Vitest for focus/theme/cover; WebdriverIO smoke on Windows + Linux CI.

**Order:** 0 scaffold + overlay spike (S) → 1 IPC + mock (M) → 2 focus manager + tests (L) → 3 primitives (M) → 4 shell (M) → 5 library grid (L) → 6 detail/launch/quit (M) → 7 claim screen (L) → 8 settings (M) → 9 theme/accent UI (S) → 10 overlay (L) → 11 perf pass on Deck + WebView2 (M) → 12 E2E (M).

**Design decisions settled with the owner after the draft:**

* **Danger color: yes.** Add `--k-danger: #FF4D4D` (same red as P1; 6.4:1 on black) and `--k-on-danger: #000000`. Allowed only on destructive buttons (fill or border + text), error toasts' glyph, and the hold-ring of a destructive HoldButton. Destructive actions use **both** red **and** hold-to-confirm by default; **`settings.players.confirm_destructive: 'hold' | 'red_only' | 'both'` (default `both`)** makes the hold configurable. Update DESIGN.md §2 (token), §2.1 (Warning/danger row → "`--k-danger` + glyph + text + hold per setting"), §2.2 (add danger to the exhaustive list), §5 HoldButton/Button (danger variant), §12 item 9 (allow `--k-danger`).
* **P6 stays violet.**
* **UI sounds:** ship a minimal in-house set (`move`, `accept`, `back`, `error`, `launch`) synthesized by `app/ui/scripts/gen-sounds.mjs` (pure-tone/click generator, no third-party samples) into `app/ui/public/sounds/`; `settings.display.ui_sounds` default **on** for desktop, **off** on Steam Deck (detected from `SteamDeck=1` env / `k_ESteamInputType_SteamDeckController` presence). Themes may replace them (already in the theme contract).

***

## Appendix A — `docs/DESIGN.md` (to be written verbatim in the first milestone)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.kouch.dev/docs/architecture.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
