> 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/design.md).

# Kouch UI design system

Enforceable rules for every screen, component and PR in `app/ui/`. If a rule here conflicts with taste, the rule wins; change the rule via PR to this file first.

Kouch is a **paged tile-grid launcher** and controller hub for up to 8 players (ADR 0026), running in a Tauri 2 webview on a 7" handheld and on a 4K TV at 3 m. It never contains third-party console names, icons, fonts, sounds or characters.

## 1. Principles

1. **Black first.** The page is `#000000`. Everything else is white at a stepped opacity, one accent, or a player color. No gradients, no shadows, no glows, anywhere.
2. **One focus, always visible.** Every input owner (player) has exactly one focused element. Focus is a ring or bar in the accent (host) or the player color (others). Nothing is hover-only.
3. **Controller is the primary input.** Navigation arrives from Rust as events. A = accept, B = back one level, Start = menu, X/Y contextual, LB/RB page. Mouse, touch and keyboard are secondary and map onto the same actions.
4. **Ten-foot legible.** Body text is 1rem = 24 px at 1080p (root scales with viewport height). Minimum readable text is `--k-fs-sm`. Targets are at least `--k-target-min`.
5. **Motion is transform + opacity only,** 60/120 Hz clean, and disappears under reduced motion.
6. **Status is never color alone.** Every player chip carries its slot number; every state has a glyph or text.
7. **Themes recolor, they do not restyle.** A theme supplies one accent hue, a few radii/durations, images and sounds. Nothing else.

## 2. Tokens

Single source: `app/ui/src/theme/tokens.css`. No other file may declare a raw color. Rust may override `--k-accent`, `--k-accent-rgb`, `--k-safe-x`, `--k-safe-y`, `--k-text-scale` and theme-allowlisted tokens at runtime by injecting `<style id="k-theme">`.

```css
:root {
  /* ---------- Color: black, white steps, one accent ---------- */
  --k-black: #000000;
  --k-white: #FFFFFF;
  --k-bg: #000000;

  /* Surfaces = white on black at fixed opacity, pre-composited (opaque). Contrast vs black in comments. */
  --k-surface-1: #0A0A0A;   /* white 4%  — 1.06:1  page-level cards          */
  --k-surface-2: #0F0F0F;   /* white 6%  — 1.10:1  tiles without art, rows    */
  --k-surface-3: #141414;   /* white 8%  — 1.14:1  sheets, toasts, panels     */
  --k-surface-4: #1A1A1A;   /* white 10% — 1.21:1  nested/pressed surfaces    */

  /* Hairlines and inks — the ONLY translucent colors allowed in CSS */
  --k-hairline:        rgb(255 255 255 / 0.12);  /* separators, tile edges (decorative)   */
  --k-hairline-strong: rgb(255 255 255 / 0.24);  /* secondary button border               */
  --k-outline:         rgb(255 255 255 / 0.55);  /* control boundaries (toggle off) 6.3:1 */
  --k-ink-pressed:     rgb(255 255 255 / 0.12);  /* pressed wash on any surface           */
  --k-ink-on-accent-pressed: rgb(0 0 0 / 0.15);  /* pressed wash on accent fills          */
  --k-scrim:           rgb(0 0 0 / 0.60);        /* modal backdrop                        */
  --k-dim:             rgb(0 0 0 / 0.40);        /* idle dim layer (OLED)                 */

  /* Text = white steps, opaque for crisp antialiasing */
  --k-text:    #F2F2F2;                          /* white 95% — 18.8:1 */
  --k-text-2:  #B3B3B3;                          /* white 70% — 10.0:1 */
  --k-text-3:  #8C8C8C;                          /* white 55% —  6.3:1  minimum for readable text */
  --k-text-disabled: rgb(255 255 255 / 0.40);    /* 3.7:1 — inactive controls only (WCAG-exempt)  */

  /* Accent: exactly one hue. Rust overrides --k-accent and --k-accent-rgb (OS accent / theme / user). */
  --k-accent:      #C4B5FD;                      /* default: pastel purple — 11.4:1 on black */
  --k-accent-rgb:  196 181 253;
  --k-on-accent:   #000000;                      /* black text on accent — 11.4:1 */
  --k-accent-a24:  rgb(var(--k-accent-rgb) / 0.24);  /* selected-row wash, progress track */
  --k-accent-a12:  rgb(var(--k-accent-rgb) / 0.12);  /* selected tab wash                 */
  --k-focus:       var(--k-accent);
  --k-selected:    var(--k-accent);

  /* Danger: the ONE exception to the single-accent rule. Destructive buttons, error toast glyph, destructive hold ring. */
  --k-danger:      #FF4D4D;                      /* 6.4:1 on black */
  --k-danger-rgb:  255 77 77;
  --k-on-danger:   #000000;

  /* Player palette: fixed, never derived from the accent. RGB triplets are the LED values. Eight players at most (ADR 0026). */
  --k-player-1:  #F03A3A;  /* 240  58  58  red         5.4:1 */
  --k-player-2:  #1E90FF;  /*  30 144 255  blue        6.5:1 */
  --k-player-3:  #FFC81A;  /* 255 200  26  yellow     13.5:1 */
  --k-player-4:  #2ECC5E;  /*  46 204  94  green       9.9:1 */
  --k-player-5:  #FF9A2E;  /* 255 154  46  orange      9.9:1 */
  --k-player-6:  #B97BFF;  /* 185 123 255  violet      7.3:1 */
  --k-player-7:  #5CE8E8;  /*  92 232 232  cyan       14.2:1 */
  --k-player-8:  #FF7AC6;  /* 255 122 198  pink        8.9:1 */
  --k-on-player: #000000;

  /* ---------- Type ---------- */
  --k-text-scale: 1;                                             /* Settings: 0.85–1.5 */
  --k-root: calc(clamp(16px, 2.222vh, 48px) * var(--k-text-scale)); /* 17.8px @800p, 24px @1080p, 48px @4K */
  --k-font: "Inter Variable", Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
  --k-fs-xs:  0.75rem;   /* 18px @1080p — captions, never essential info */
  --k-fs-sm:  0.875rem;  /* 21px — secondary, chip numbers                */
  --k-fs-md:  1rem;      /* 24px — body, rows, prompts                    */
  --k-fs-lg:  1.25rem;   /* 30px — focused-tile title, section headers    */
  --k-fs-xl:  1.5rem;    /* 36px — sheet titles                           */
  --k-fs-2xl: 2rem;      /* 48px — screen titles                          */
  --k-fs-3xl: 3rem;      /* 72px — slot numbers on the claim screen       */
  --k-lh-tight: 1.2;
  --k-lh-body:  1.4;
  --k-fw-regular: 400; --k-fw-medium: 500; --k-fw-semibold: 600; --k-fw-bold: 700;

  /* ---------- Spacing: 4px grid at the 1080p reference (1 unit = 1rem/6) ---------- */
  --k-unit:    calc(1rem / 6);
  --k-space-1: calc(var(--k-unit) * 1);   /*  4px */
  --k-space-2: calc(var(--k-unit) * 2);   /*  8px */
  --k-space-3: calc(var(--k-unit) * 3);   /* 12px */
  --k-space-4: calc(var(--k-unit) * 4);   /* 16px */
  --k-space-5: calc(var(--k-unit) * 6);   /* 24px */
  --k-space-6: calc(var(--k-unit) * 8);   /* 32px */
  --k-space-7: calc(var(--k-unit) * 12);  /* 48px */
  --k-space-8: calc(var(--k-unit) * 16);  /* 64px */

  /* ---------- Radius ---------- */
  --k-radius-sm:   0.25rem;   /*  6px */
  --k-radius-md:   0.5rem;    /* 12px */
  --k-radius-lg:   0.75rem;   /* 18px */
  --k-radius-tile: 0.75rem;   /* rounded-square tiles */
  --k-radius-pill: 999px;

  /* ---------- Border ---------- */
  --k-hairline-w:   max(1px, 0.04rem);     /* 1px up to 1440p, ~2px at 4K */
  --k-focus-w:      max(3px, 0.1667rem);   /* 4px @1080p */
  --k-focus-offset: max(4px, 0.25rem);     /* 6px @1080p */

  /* ---------- Motion ---------- */
  --k-dur-press: 90ms;
  --k-dur-focus: 170ms;
  --k-dur-page:  260ms;
  --k-dur-sheet: 220ms;
  --k-dur-fade:  150ms;
  --k-ease-out:    cubic-bezier(0.2, 0.8, 0.2, 1);
  --k-ease-in:     cubic-bezier(0.4, 0, 1, 1);
  --k-ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
  --k-tile-focus-scale: 1.08;
  --k-tile-press-scale: 1.04;

  /* ---------- Z-index (main window) ---------- */
  --k-z-bg: -1; --k-z-page: 0; --k-z-chrome: 10; --k-z-sheet: 100;
  --k-z-modal: 200; --k-z-toast: 300; --k-z-dim: 400; --k-z-debug: 900;

  /* ---------- Safe area (Rust injects from Settings; default 5% TV overscan) ---------- */
  --k-safe-x: 5vw;
  --k-safe-y: 5vh;

  /* ---------- Layout ---------- */
  --k-strip-h:    2.5rem;                    /* status strip  60px @1080p */
  --k-bar-h:      3rem;                      /* bottom bar    72px        */
  --k-tile:       clamp(9rem, 26vh, 18rem);  /* 208px @800p, 281px @1080p, 562px @4K */
  --k-gutter:     1rem;
  --k-glyph:      1.25rem;                   /* button glyph box */
  --k-target-min: max(44px, 2rem);           /* touch/focus target */
  --k-panel-w:    34vw;                      /* overlay panel; 100vw under 900px */
}

html { font-size: var(--k-root); background: var(--k-bg); color: var(--k-text); font-family: var(--k-font); }

@media (prefers-reduced-motion: reduce) { :root { --k-dur-press: 0ms; --k-dur-focus: 0ms; --k-dur-page: 0ms; --k-dur-sheet: 0ms; --k-dur-fade: 100ms; --k-tile-focus-scale: 1; --k-tile-press-scale: 1; } }
html[data-motion="reduced"]              { --k-dur-press: 0ms; --k-dur-focus: 0ms; --k-dur-page: 0ms; --k-dur-sheet: 0ms; --k-dur-fade: 100ms; --k-tile-focus-scale: 1; --k-tile-press-scale: 1; }
@media (max-width: 899px) { :root { --k-panel-w: 100vw; } }
```

### 2.1 Color derivation rule (the only way to make a new color)

| Need            | Derive as                                                                                                                                              | Never                                 |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- |
| Elevation       | next `--k-surface-N` + `--k-hairline`                                                                                                                  | shadows, glows, gradients             |
| Pressed         | `--k-ink-pressed` (on surfaces) or `--k-ink-on-accent-pressed` (on accent) via `::before` overlay                                                      | a darker/lighter hex                  |
| Hover           | none. Pointer movement moves focus; focus is the feedback                                                                                              | hover styles                          |
| Disabled        | `opacity: 0.4` on the control + reason text in `--k-text-3`                                                                                            | grey hexes                            |
| Emphasis        | `--k-accent` (see 2.2)                                                                                                                                 | second hue                            |
| Destructive     | `--k-danger` fill/border + warning glyph + verb label; hold-to-confirm per `settings.players.confirm_destructive` (`both` default, `hold`, `red_only`) | yellow/green, any second "status" hue |
| Player identity | `--k-player-N` fill with `--k-on-player` number                                                                                                        | tints of the accent                   |
| Anything else   | not allowed; propose a token in this file                                                                                                              |                                       |

### 2.2 Accent and danger usage (exhaustive)

`--k-accent` may appear only as: focus ring/bar (host player), selected state (check glyph, selected text, `--k-accent-a24` row wash, active-tab underline), primary button fill with `--k-on-accent` text, progress fills and non-destructive hold-to-confirm rings, active-player highlight (the navigating player's chip ring in the status strip), page-dot active state, toggle-on track, slider fill. Nothing else.

`--k-danger` may appear only as: destructive Button/HoldButton fill (with `--k-on-danger` text) or border, the hold ring of a destructive HoldButton, and the glyph of an error toast. Never as text on black, never as a focus ring, never decoratively.

### 2.3 Accent source and validation (Rust)

Priority: user override in Settings → active theme `--k-accent` → OS accent (if enabled) → default `#C4B5FD`.

OS accent readers: Windows `UISettings::new()?.GetColorValue(UIColorType::Accent)` (`windows` crate, feature `UI_ViewManagement`); GNOME `org.gnome.desktop.interface accent-color` enum mapped through the libadwaita table (blue `#3584e4`, teal `#2190a4`, green `#3a944a`, yellow `#c88800`, orange `#ed5b00`, red `#e62d42`, pink `#d56199`, purple `#9141ac`, slate `#6f8396`); KDE `~/.config/kdeglobals` `[General] AccentColor=r,g,b`, else the scheme's `[Colors:Selection] BackgroundNormal` (the Deck's Desktop Mode has only the latter); xdg-desktop-portal `org.freedesktop.appearance` `accent-color` `(ddd)` preferred when available (UNVERIFIED). Steam Deck / SteamOS gaming mode: no OS accent, use default.

Gate (any failure → fall back to the next source, log the reason, never lift/adjust the color):

1. Contrast vs `#000000` ≥ **4.5:1** (accent is used as text and as a ring on black). Rejects dark blues/greys (e.g. `#005A9E` 2.96:1, GNOME purple `#9141ac` 3.56:1); accepts Windows default `#0078D4` 4.64:1 and GNOME blue 5.57:1.
2. Saturation: `max(r,g,b) − min(r,g,b) ≥ 40` (rejects greys/whites that would be indistinguishable from the white steps).
3. Opaque 6-digit hex only.

Rust always injects `--k-accent`, `--k-accent-rgb` and `--k-on-accent` — black or white, whichever contrasts more with the accent (gate 1 guarantees black clears 4.5:1, but a mid-luminance accent such as the Windows default violet `#686BC6` reads better with white) — together with `accent_source` for the Settings UI.

## 3. Typography

* Stack: `--k-font`. Bundled: **Inter** variable (SIL Open Font License 1.1, "Copyright (c) 2016 The Inter Project Authors") at `app/ui/public/fonts/InterVariable.woff2` + `LICENSE.txt` (exact filename/size UNVERIFIED; subset to Latin + Latin-Ext). `font-display: block`. Enable `font-optical-sizing: auto` (opsz axis UNVERIFIED).
* Root follows the stage (§4): 20 px × min(w/1440, h/900), **rounded to a whole pixel** (`round()` in `theme/tokens.css`); every size, spacing and radius is rem and the size tokens round too. Body `--k-fs-md` = 24 px at 1080p, 18 px on a 1280×800 handheld, 48 px at 4K.
* Line heights are whole, even pixels: a component sets a ratio in `--k-lh` (it inherits like a unitless line-height) and one rule turns it into each element's own rounded height. Never write `line-height` directly except for a fixed box.
* Numbers in clock, battery, counters, slot labels: `font-variant-numeric: tabular-nums`.
* Weights: 400 body, 500 rows/prompts, 600 titles, 700 slot numbers. No italics. No all-caps except single-letter glyph fallbacks.
* Truncation: single-line `text-overflow: ellipsis`; tile covers clamp to 3 lines (`-webkit-line-clamp`).

## 4. Layout

**The stage (HOME\_LIBRARY\_PLAN §1).** Every screen is laid out for a 1440 × 900 reference and scaled to its display: `--k-scale` = min(w/1440, h/900) (0.75–3), written by `lib/stage.ts` with `data-density` (compact ≤ 1366 wide or ≤ 820 tall; large from 2560 at 16:9). Extra width on 21:9 and 32:9 becomes more columns or cards, never bigger UI. A screen fills its display at any size and re-fits on resize; only a genuinely long list scrolls, inside its pane.

**Whole pixels.** Positions and sizes land on whole pixels: tokens round (`--k-snap` / `--k-snap-2` steps; heights that something centres in are even), text slots round their width up (`lib/pixel.ts` `wholeWidth`), auto-fit grids lay out whole-pixel tracks (`wholeGrid`), and arithmetic layouts (Home, the Library) floor their numbers. A sparse block grows to its room with `lib/fit.ts` `fitScale` (a fit scope that scales its own tokens, never CSS zoom).

**The prompt bar's band.** The one prompt bar sits `--k-promptbar-bottom` above the bottom edge; the shell reserves `--k-promptbar-h` under every base screen, and panels and sheets scroll in a body that ends above it — nothing ever scrolls under the bar.

**Checks.** `scripts/ui-shoot.mjs` `fit:` (bands ≤ 0.12, 0.14 for header/footer margins; 0 clipped, 0 outside, 0 under the prompt bar) and `align:` (0 near misses, 0 sub-pixel crisp boxes) at 1280×800, 1366×768, 1920×1080, 2560×1080, 3440×1440, 3840×2160 and 150 % text, in Black and White.

**Home** (boards H1–H21, `home/layout.ts`): a 2 × 4 widget column (432 at 1440, 360 compact) and the right column: the last-played card, one game row (a wide first card then tall ones) and the bottom strip. Right from the big card folds the widgets into an 88 px rail of 64 px glances and widens both rows (§4b). Both layouts are computed from the content box, never measured.

**Library** (boards LF1–LF17): Systems tiles 416 × 300 at 1440, three across (more on wide screens), never so tall that two rows stop fitting; Grid A–Z covers 191 wide at 1440, six across (seven in All games), rows tuned so only whole rows show; Shelf spines 52 × 222–262 (from the title's length) pulling out to 150 × 266; Playtime rows 60 high.

Sheets: slide from the right, width `round(clamp(28rem, 40vw, 44rem))`. Modals: centered, max-width 36rem. Overlay panel: left, `--k-panel-w`, full height.

### 4.1 Focus motion: one gliding ring per region

Wherever focus steps across many items — Home's right side, a Home row, the widget column, the Systems tiles, Grid A–Z, a Shelf, the Playtime list, Edit Home's blocks — the motion is **one ring per region** (`components/FocusRing.svelte`): it takes the new item's rect at once and glides from the old one with a transform-only FLIP (translate + scale, origin set in CSS, never in the keyframes). Items change state **instantly** and draw no ring of their own (their `::after` is hidden inside the region). The rect comes from the region's own arithmetic, never a layout read during motion. The ring fades in where it lands and out where it was; under reduced motion it simply moves.

Why (Deck, WebKitGTK, 2026-09-27): per-card focus transitions created and dropped compositing layers on every step, and only "no card transitions + one persistent ring layer" held 90 Hz. A region's own scrolling that follows a focus change runs after the frame paints (rAF, then the scroll), and its groups are `noScroll` so the manager never scrolls a clipped track mid-animation. Lists past \~60 items are windowed with pooled cells (a scroll re-points cells, it doesn't build new ones).

## 4b. The Plan v2 shell (Big Picture structure)

Kouch follows Big Picture's *structure* with its own art, icons and Inter:

```
top bar      = menu button + section title | downloads, player dots, Steam user, clock, quick access button
base screen  = Home (hero + shelves) or Library (tabs + grid); the other stays mounted, display:none
prompt bar   = bottom right of the safe box on every screen, clickable, one order (§7): Start/View · LT/RT · LB/RB · Y · X · A · B
layers       = game page, Players, Settings, Downloads, first-run Setup: full screens over the base, each a trapped focus scope
edge panels  = main menu (Start, left, --k-menu-w) · quick access (View, right)
```

* **Home** (HOME\_LIBRARY\_PLAN §3). Widgets on the left (Friends online, Controllers, Storage, Downloads by default; 26 in the catalogue, each with a glance), the last-played card (large art, with save and time, or a compact banner that frees a second row), one game row (8 types; placeholders fill a short row), the bottom strip (Your week, couch, friends, downloads, a second row, or nothing). A plays (Resume), hold A opens the game page, X favorites, Y opens Edit Home. Right from the big card folds: widgets fade out 0–140 ms, glances in from 100 ms staggered 20 ms, the rows widen and the top row becomes an accordion of the recent games (owner 2026-09-28, board "ExpandedRight"): cards in order, earlier games on the left, the focused one wide where it stands and the others narrow with their day label; Right/Left move focus one card and the highlight travels with it, and the row scrolls only as far as it must to keep the wide card whole with a neighbour peeking on each side (`home/layout.ts` `topRowScroll`). A narrow card last played online with friends wears the first friend's dot, top right. Every block is a 260 ms FLIP (art scales, captions fade in at the end). Left past the first card or B unfolds. Captions over art sit on a solid `--k-plate`.
* **Edit Home** (§3.7). Dashed blocks over Home's own layout with kind chips: A picks a widget up and the d-pad moves it (a neighbour in the way swaps), Y its size or settings (the card's style, the row's type, the strip's content), X removes it (a Free slot stays), "Add a widget" opens the grouped catalogue; B saves.
* **Library** (§4). LB \[Systems]\[Collections]\[Playtime]\[All games] RB; LB/RB switch sections from anywhere but inside a system. Systems: tiles with states (drive not connected, no games, needs an emulator, N new). A system opens in its remembered view — Grid A–Z (letters, rail, LB/RB a letter), Shelf or Playtime — under a breadcrumb; View picks the view, Y the order and filter (per system), B goes back to the tile. All games is Grid A–Z across every system with a system badge. An unplugged drive's games stay greyed under a banner that says why.
* **Game page.** Hero art behind (dim), cover card, title or logo, Play (the one primary) + favorite; tabs Options (emulator picker, the profile's options grouped Graphics/Controls/Audio/Multiplayer/Advanced, reset; a line when the emulator has none), Mods, Netplay, Details. Tabs follow focus and LB/RB. Under the tabs the page fills the screen with two plates side by side down to the prompt bar: the tab body, and **At a glance** (§10f). Y opens the tags sheet from anywhere on the page. The page scrolls as one when a tab holds more (the head moves away first).
* **Card.** One component for every card: portrait 2:3 (grid art, else cover art), square, landscape 16:9; width `--k-card-w-home` / `--k-card-w-grid` (capped by the height available, so 21:9 and 150 % keep whole rows on screen); the generated Cover sits underneath and art fades in over it once decoded; the title/system label shows under the card only while focused.
* **Black / White.** `html[data-scheme="white"]` mirrors every neutral token (surfaces = black at 4–10 %, text = black steps, hairlines/inks black-based) and darkens player colors to ≥ 3.3:1 on the light surfaces; the accent is re-gated in Rust against white (fallback `#6D28D9`). The page is pure `#000` / `#FFF` — only real hero art may tint it, at ≤ 0.25.
* **Ambient background.** In-house rounded tiles, circles, rings and glyph outlines at 3–6 % in `--k-text` (one in six in the accent), drifting via transform only at ≤ 30 fps; paused while a game runs or the window is hidden/unfocused, frozen under reduced motion, off with `display.ambient`.
* **Sound.** Slots move, accept, back, error, launch, page, sheet open/close, pad connected/disconnected, player joined, toast, boot (all from `scripts/gen-sounds.mjs`); optional music is generated live with Web Audio (`lib/music.ts`), off by default; volume = master × UI / master × music.
* **Motion.** Everything that appears or leaves animates: sheets/panels slide, modals pop, scrims fade (`lib/motion.ts`, WAAPI, transform/opacity, token durations — instant under reduced motion); focus scrolling glides (`scroll-behavior: smooth`).
  * **Screens and layers, too (owner 2026-09-28: "smooth … between library sections … transitions/menu").** Layers (Settings, Players, Downloads, Setup) rise 2rem and fade in over `--k-dur-page` and fade out over `--k-dur-fade`; the game page fades in and out. A Library section slides 3rem in from the side of its tab and fades up over `--k-dur-focus`; a system slides in from the right, and the Systems tiles come back from the left. Home and the Library rise in when they become the base screen. Kept-mounted views use `reveal` (`lib/motion.ts`, which plays when a view turns shown); mounted ones use `in:`/`out:` transitions. Leaving is instant for a view that another one replaces in the same place: the next one is already arriving.

## 5. Components

State vocabulary: **default / focused / pressed / disabled / selected**. Focus ring = `::after` pseudo-element, `inset: calc(-1 * var(--k-focus-offset))`, `border: var(--k-focus-w) solid var(--k-focus)`, `border-radius: calc(inherit + offset)`, `opacity 0→1` over `--k-dur-focus`. The ring rule matches `:focus` **and** `[data-focus-host]` (the focus manager sets the attribute on the host cursor; browsers only paint `:focus` while the window is focused, and the overlay window never is). For non-host players the ring color is `--k-player-N` on `[data-focus-p]`. Rows (list rows, toggles, sliders, text fields) wear the same all-round ring on focus, inset (`inset: 0`, the row's `--k-radius-md` corners), over a `--k-surface-4` fill; a fill or a bar alone doesn't read on a sheet (the Deck plan D4, 2026-09-28).

| Component                                                                               | Size (1080p ref)                                                                                                                 | Default                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Focused                                                                               | Pressed                                                                                                                                                                                                                                          | Disabled                                                                          | Selected                                                        | Tokens                             |
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------- |
| **Tile**                                                                                | `--k-tile` square, `--k-radius-tile`                                                                                             | art (`object-fit: cover`) or generated Cover; `--k-hairline` edge                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | scale `--k-tile-focus-scale`, ring, z above siblings, title shown in focus-title line | scale `--k-tile-press-scale` for `--k-dur-press`                                                                                                                                                                                                 | art at 40% opacity + "missing file" glyph; still focusable, reason in focus-title | (later) accent check chip top-right                             | tile, radius-tile, hairline, focus |
| **Tile page / grid**                                                                    | cols×rows per §4                                                                                                                 | pages row                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | one focused tile per owner                                                            | —                                                                                                                                                                                                                                                | —                                                                                 | —                                                               | gutter, dur-page                   |
| **Top bar** (`TopBar.svelte`; the 8-dot `StatusStrip` survives only in the dev gallery) | h `--k-strip-h`; player dots 0.75rem, gap 0.375rem; picture 1.5rem square                                                        | left: menu button, then the screen's title; right: download progress while one runs, **one dot per seated player whose pad is here** (player colour; a seat whose pad is away shows no dot; nobody here: one hollow `--k-hairline-strong` ring keeps the button's place; eight dots below 68.75em, none below 47.5em), battery on a handheld, **Steam user** (square picture with your Steam frame and a presence dot, persona name `--k-fs-sm`; one focus item that opens the Steam profile card), clock `tabular-nums`, quick access                                                                                                                                                                                                                                                                                                                               | the dot cluster is one focus item (ring around the cluster) and opens Players         | —                                                                                                                                                                                                                                                | —                                                                                 | the navigating player's dot has an accent ring                  | player-N, text-2, fs-sm            |
| **Bottom bar / prompt bar**                                                             | h `--k-bar-h`                                                                                                                    | left: section tabs (Library, Players, Settings) `--k-fs-md` 500; right: prompts (PromptBar, §7) in order Start/View, LT/RT, LB/RB, Y, X, A, B                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | tab: focus bar                                                                        | tab: `--k-ink-pressed`                                                                                                                                                                                                                           | tab: opacity .4                                                                   | active tab: accent 0.125rem underline + accent text             | fs-md, accent, glyph               |
| **Prompt**                                                                              | glyph box `--k-glyph` + label `--k-fs-md`                                                                                        | glyph + label, always both                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | n/a (not focusable)                                                                   | —                                                                                                                                                                                                                                                | opacity .4                                                                        | —                                                               | glyph, text                        |
| **Player slot chip**                                                                    | dot 0.75rem / chip 1.75rem / card 6rem                                                                                           | fill `--k-player-N`, number `--k-on-player` 700; empty = hairline ring + number in `--k-text-3`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | ring in accent (host) or own color                                                    | scale .96                                                                                                                                                                                                                                        | opacity .4                                                                        | —                                                               | player-N, on-player, fs-sm/3xl     |
| **Motion badge**                                                                        | 1rem circle at chip corner                                                                                                       | white 24% fill, wave glyph                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | —                                                                                     | —                                                                                                                                                                                                                                                | —                                                                                 | assigned: `--k-text` fill, black glyph, motion slot number      | hairline-strong, text              |
| **Button** (primary/secondary/tertiary)                                                 | h `--k-target-min`, px `--k-space-5`, `--k-radius-md`                                                                            | primary: accent border + accent text at rest, accent fill + `--k-on-accent` only while focused (one thing looks selected at a time; danger: its border at rest, its fill focused); secondary: `--k-hairline-strong` border + `--k-text`; tertiary: text only                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | ring                                                                                  | `::before` wash (`--k-ink-on-accent-pressed` / `--k-ink-pressed`)                                                                                                                                                                                | opacity .4                                                                        | —                                                               | accent, on-accent, hairline-strong |
| **HoldButton**                                                                          | as Button + progress ring `--k-focus-w`, tracing the button outline                                                              | secondary look + hourglass glyph + "Hold" label; **destructive variant**: `--k-danger` border (or fill when `red_only`) + warning glyph; ring hidden at rest                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | ring                                                                                  | progress traces the outline in accent (or `--k-danger` for destructive) over `hold_ms` (≥ 1000), from A/pointer press; release early = cancel; Rust's `long` phase is ignored; with `confirm_destructive: red_only` it behaves as a plain Button | opacity .4                                                                        | —                                                               | accent, danger, hairline-strong    |
| **Modal / Sheet**                                                                       | §4                                                                                                                               | `--k-surface-3`, `--k-hairline` edge, title `--k-fs-xl` 600, body `--k-fs-md`, `--k-scrim` behind; a confirm row (Cancel / the action) lives in the Sheet's `footer` snippet, pinned under the scroller, never at the end of the list; secondary actions left, the primary rightmost                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | focus trapped inside                                                                  | —                                                                                                                                                                                                                                                | —                                                                                 | —                                                               | surface-3, scrim, dur-sheet        |
| **List row**                                                                            | min-h 3rem, px `--k-space-5`                                                                                                     | leading glyph/chip, label `--k-fs-md` 500, value `--k-text-2` right, trailing chevron/control                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | all-round ring + `--k-surface-4` fill                                                 | `--k-ink-pressed`                                                                                                                                                                                                                                | opacity .4 + reason `--k-text-3`                                                  | accent check glyph + label in accent                            | surface-4, text-2, accent          |
| **Toggle**                                                                              | 3rem × 1.75rem, pill                                                                                                             | off: `--k-outline` border, knob `--k-text-2`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | row focus                                                                             | knob scale .9                                                                                                                                                                                                                                    | opacity .4                                                                        | on: accent track, knob `--k-on-accent`; knob moves by transform | outline, accent                    |
| **Slider**                                                                              | track 0.25rem, thumb 1.25rem                                                                                                     | track `--k-hairline-strong`, fill accent, thumb `--k-white`, value `tabular-nums` right                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | row focus; left/right steps (Rust repeat accelerates)                                 | thumb scale 1.15                                                                                                                                                                                                                                 | opacity .4                                                                        | —                                                               | accent, hairline-strong            |
| **Toast**                                                                               | max-w 36rem, above bar, bottom-center; glides up under the top bar while it would cover the focused control (`lib/toastDock.ts`) | `--k-surface-3`, hairline, glyph + `--k-fs-md`; 4 s; one visible, queued                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | not focusable                                                                         | —                                                                                                                                                                                                                                                | —                                                                                 | —                                                               | surface-3, dur-fade                |
| **Overlay panel (Quick Menu)**                                                          | its window (the shell sizes it to 34 % of the game's screen, min 420 px) × 100vh                                                 | `--k-surface-3` like the Start menu, `--k-hairline` right edge; Now playing header, seat squares, Start-menu rows (§10f)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | rows as the Start menu's (filled + focus border)                                      | —                                                                                                                                                                                                                                                | reserved rows: disabled label + the reason as the row's second line               | —                                                               | surface-3, panel-w                 |
| **Cover (generated)**                                                                   | fills tile                                                                                                                       | a pattern drawn from the game's seed alone (`lib/cover.ts` `coverArt`: mulberry32 over fnv1a32 of `Game.art_seed`, else the id): one of seven families (orbs, bands, dots, peaks, rings, tiles, arcs), its shapes' count, sizes and positions, and a palette around one hue on a ground `hsl(H 35–55% 24–28%)`; the same file draws the same cover on every machine; drawn by the shell into its thumbnail cache (`Game.cover`, a 400×560 lossless WebP, a Rust port of `lib/cover.ts` checked against it) and shown exactly like art, the ground until it loads (drawing it in the page cost the Deck frames: an SVG background, a canvas per card and page-side bitmaps each did; the page-side generator is left for the dev mock only); title `--k-text` `--k-fs-lg` 600, 3-line clamp, padding `--k-space-4`; system label `--k-fs-sm` `--k-text-2` bottom-left | —                                                                                     | —                                                                                                                                                                                                                                                | —                                                                                 | —                                                               | text, fs-lg                        |

Generated covers are content (like game art), not chrome: they are the only runtime-computed color and are always set as an inline style from `lib/cover.ts`, never in CSS. The title stays readable: the ground and every shape that reaches the lower half keep `--k-on-cover` at ≥ 4.5:1 (L ≤ 26–28 %); lighter accents only sit wholly in the top half, above any title. Card sizes never vary with the art (the grid stays aligned).

## 6. Navigation rules

1. Input arrives only via the `nav:input` event `{ player_id, device_type, action, phase, ts_ms }` with `action ∈ up|down|left|right|accept|back|menu|x|y|lb|rb|lt|rt`, `phase ∈ press|repeat|release|long`. Repeat cadence is Rust's; the UI treats `repeat` like `press` for directional actions only.
2. A = accept the focused item. B = pop exactly one level (sheet → screen → previous screen); at the library root B does nothing (soft blip). Start (`menu`) toggles the Quick Menu in every context while a game runs. In the shell, Start on a focused game (any card, a Library row, anywhere on the game page) opens that game's menu, the same one hold X opens (GAME\_MENU\_EXPORT\_PLAN G1: Play, favorite, collections, tags, emulator profile, go to emulator, finished, hide, game page); anywhere else it opens the main menu, and the top bar's ≡ opens the main menu everywhere. An item opts in with `use:focusable`'s `onMenu`, a whole group (the game page) with its group def's `onMenu`. X/Y are contextual and always shown in the prompt bar when active. LB/RB page the grid or change settings categories. LT/RT reserved.
3. One focused element per input owner. Host (P1 by default, `players.set_navigator` to change) owns the library, settings and sheets. Every connected player owns their own cursor on the claim screen and the players panel.
4. Focus groups are explicit (`grid | row | column | free`) with declared exits (`up/down/left/right → groupId`) and wrap policy (`none | wrap | next-group`). Defaults: grids and lists do not wrap; grid horizontal edge → adjacent page; grid top edge → status strip; grid bottom edge → bottom bar.
5. Focus memory: each screen stores the last focused item per group; restored on return, on page revisit, and on return from a game (focus the tile that was launched).
6. Modals and sheets trap focus; opening pushes a scope, B pops it and restores the previous focus.
7. Long-press (`phase: long`, 600 ms in Rust) on a tile opens its detail sheet. Destructive actions (delete state, reset, force quit, disconnect) use HoldButton (≥ 1000 ms) with a visible progress ring; release cancels.
8. Secondary input: pointer move over a focusable moves focus (no separate hover style); click/tap = focus + accept (a whole tap: press + release, so tap-and-hold items act); right-click = the item's X hold (a card's menu) where it has one, else an X tap; the mouse's back button (4) = B; swipe (wheel) on the grid pages it, the same as LB/RB; keyboard/mouse/touch map to the host player. **The keyboard is a whole pad in every build** (owner, 2026-09-29; the one table is `lib/keymap.ts`): arrows = the d-pad, Enter/Space = A, Esc/Backspace (and a keyboard's Back key, BrowserBack) = B, X = X, Y = Y, M/Home = Start, Q/End = View, Page Up/Page Down or \[ / ] = LB/RB, , / . = LT/RT, holding a key = a held button. Letters match either case, a text field keeps its caret's keys, Ctrl/Alt/Meta chords are never nav, and Tab / Shift+Tab walk the whole screen in reading order, wrapping, like a web page (`App.svelte`). While the keyboard is the last input, prompts show its keys as Kenney's CC0 key glyphs (`glyphs/keyboard.ts`; LB/RB as \[ and ], whose legends stay legible at 1280×800), text keycaps only for a key without one (§7). Exception: a HoldButton (§6.7) still moves focus on pointer move, but click/tap never accepts it — pointer-down starts the hold and pointer-up before `hold_ms` cancels it, exactly like a controller press/release, so a stray click can never complete a destructive action; `confirm_destructive: red_only` turns it into a plain Button (§5), where click does accept.
9. Disabled items stay focusable and explain why; accept does nothing.
10. Everything is reachable by d-pad alone; if a reviewer cannot reach it with arrows + Enter + Esc, it fails review.
11. Drag-and-drop is an accelerator only: dropping a `.kouch-profile` (or a folder, on Settings > Paths) anywhere on the window opens the same confirmation Sheet the d-pad path opens; nothing is ever imported without that Sheet.
12. A region that expands on focus expands on pointer dwell too (Home: resting on the widget column unfolds it, resting on the right side folds it and widens the recent games). Only a real pointer move counts (the page moving under a still mouse doesn't), after \~180 ms in the same region (crossing the screen doesn't flap), and never within \~1 s of a pad or keyboard press. It is behaviour, not a `:hover` style, and uses the same transform-only animation as the keys.

## 7. Glyph rules

* Glyphs come from Rust (`glyph.get`) as SVG text obtained from Steam Input's `GetGlyphSVGForActionOrigin` (Rust reads the returned path — UNVERIFIED whether it is a path or inline SVG — and returns the SVG text). Rendered inline at `--k-glyph`, never recolored, never scaled non-uniformly, cached per `(device_type, action)`.
* **The default style is Kouch's bundled Steam Controller set** (ADR 0025; Settings › Controllers › Button glyphs, `controllers.glyph_style: "steam_controller"`): Kenney's CC0 Steam Controller glyphs in `app/ui/src/assets/glyphs/` (`glyph-south`, `glyph-lb`, …, from `scripts/import-glyphs.mjs`), `currentColor` paths drawn the same on every pad, with or without Steam, and never asked of `glyph.get` (`glyphs/steamController.ts`). The keyboard still shows its keys, and an action with no such button (motion) falls back as below. **Match each controller** (`auto`) asks Steam for each pad's own family.
* Fallback set (no Steam, keyboard, touch, dev): in-house monochrome glyphs in `app/ui/public/glyphs/fallback/`: a circle with a letter (A/B/X/Y), rounded rectangles for bumpers/triggers, a key cap for keyboard. Drawn by us; no third-party console glyphs.
* A prompt is always glyph + label. Never label-only, never glyph-only.
* The prompt bar shows only actions that do something right now, and each label says what A does here ("Add an emulator", "Use this disk", "Open"), not a generic "Select" when a better word exists.
* **One bar, one spot, one order, on every screen** (owner, 2026-09-27). Every prompt list is drawn by `components/PromptBar.svelte`: fixed at the bottom-right of the safe box (`right: --k-safe-x`, `bottom: --k-safe-y / 2`, height `--k-bar-h`), right-aligned, in the order of `lib/promptOrder.ts`: Start/View, LT/RT, LB/RB, Y, X, **A, B** — B is always rightmost and A just left of it, so both land on the same spot everywhere. Screens, layers (Settings, Players, the wizard), sheets and the on-screen keyboard each render their own PromptBar with a level (0 shell, 1 layer, 2 sheet/panel, 3 keyboard); only the topmost shows, like Big Picture's single bar. A screen never draws prompts of its own elsewhere, and keeps its content clear of the bar's corner. Content buttons (the wizard's Later/Back/Next) are content and sit on the left. The one exception is the in-game Quick Menu, which draws over a game and keeps its prompts on its own panel, in the same order.
* Prompts are also click targets for pointer users; they are never focusable.

## 8. Copy rules

* Sentence case everywhere ("Quit game", not "Quit Game"). No exclamation marks. Digits for numbers.
* All user-facing strings live in `app/ui/src/i18n/en.json`; markup contains no literal prose.
* No console or emulator brand names in strings. Refer to the user's configured profile by its user-given name (that is user data, not our string).
* "motion", not "gyro" (except keys under `settings.advanced.*`). "controller", not "gamepad". "press", not "click". "Player 3" in sentences; "P3" only on chips.
* Buttons are verbs ("Launch", "Save state"). Errors say what happened and what to do next ("Couldn't start the game. Check the emulator path in Settings.").
* Strings are checked against `docs/brand-blocklist.txt` (owner-supplied; one term per line, `#` comments, case-insensitive whole word, trailing `*` = prefix match).

## 9. Accessibility

* Text ≥ 4.5:1 on its background (large text ≥ 3:1). Verified on black: `--k-text` 18.8, `--k-text-2` 10.0, `--k-text-3` 6.3, accent 11.4; on `--k-surface-4` `--k-text-2` is 8.6. Control boundaries use `--k-outline` (6.3:1).
* Reduced motion: honors `prefers-reduced-motion` and the in-app setting (`html[data-motion="reduced"]`). Scale and slide become instant; only ≤ 100 ms fades remain.
* Text scaling: Settings 85–150% (`--k-text-scale`); layout must survive 150% at 1280×800 (grid recomputes cols/rows; sheets scroll).
* Colorblind: P1–P4 are pairwise distinct (ΔE76 ≥ 20) under simulated protanopia, deuteranopia and tritanopia (Machado 2009 matrices). P5–P8 are not guaranteed (deutan confuses P2/P6, P3/P5, P7/P8). Therefore every chip shows its slot number and the strip dot exposes it on focus. Controller LEDs use the same RGB; the number on screen is the guaranteed cue.
* Nothing flashes faster than 3 Hz. Hold rings fill, they do not blink.
* OLED care: after `idle_dim_min` (default 5) without input, a `--k-dim` layer fades in (opacity only); any input removes it.
* Semantics kept for the future: focusable elements carry `role` and `aria-label`; tiles `aria-label="{title}, {system}"`.

## 10. Theme override contract

Themes are data. They can set allowlisted tokens, supply images and sounds, and nothing else. No CSS, no selectors, no scripts, no fonts.

```json
{
  "$schema": "kouch-theme/1",
  "name": "Night Lilac",
  "version": "1.0.0",
  "author": "someone",
  "mode": "dark",
  "tokens": {
    "--k-accent": "#C4B5FD",
    "--k-radius-tile": "0.75rem",
    "--k-dur-focus": "170ms"
  },
  "assets": {
    "background": "bg.webp",
    "tileFallback": "tile.webp",
    "sounds": { "move": "move.ogg", "accept": "accept.ogg", "back": "back.ogg", "error": "error.ogg", "launch": "launch.ogg" }
  }
}
```

Allowlist (`docs/theme-token-allowlist.json`, consumed by Rust via `include_str!` and by the UI):

| Token                                 | Type   | Range            |
| ------------------------------------- | ------ | ---------------- |
| `--k-accent`                          | hex6   | passes §2.3 gate |
| `--k-radius-sm/md/lg/tile`            | rem    | 0–1.5rem         |
| `--k-dur-press/focus/page/sheet/fade` | ms     | 0–400            |
| `--k-bg-image-opacity`                | number | 0–0.6            |

Rules: `mode` must be `dark` (light reserved). `--k-bg` stays `#000000`. Background image is drawn by an `<img>` at `--k-z-bg` with the given opacity, flat, no overlay tint. Assets: relative paths without `..`, extensions webp/png/jpg/ogg/wav, background ≤ 4 MB, sounds ≤ 200 KB each.

Loading: Rust validates (schema, allowlist, ranges, accent gate, asset constraints), builds `:root{--k-accent:…;--k-accent-rgb:…;…}` and emits `theme:changed { css, assets }`. The UI sets `document.getElementById('k-theme').textContent = css` and swaps asset URLs (Tauri asset protocol — UNVERIFIED in this session; fallback is base64 from Rust). Invalid theme → toast with the reason, previous theme stays.

Default tile art: no bundled game art. Tiles use the generated Cover (§5) until the user supplies art via the detail sheet or a theme `tileFallback` (which replaces the flat fill, not the title text).

## 10b. Steam profile card

Opened from the status strip's user block (A) on any screen; a Sheet (§4). Shows everything Steam exposes for the signed-in user through the Steamworks API the app already initialises — no web calls: large avatar (`--k-radius-lg`, 6rem), persona name (`--k-fs-xl`), Steam level chip, online state, current rich-presence line, Steam ID (`tabular-nums`, `--k-text-3`), and a "Friends" row (count + the first few avatars) reserved for the invite flow. Rows: "Open Steam profile" (Steam overlay → `steam://url/SteamIDPage/<id>`), "Friends" (overlay friends dialog). B closes. Nothing here is editable; the persona and avatar are read-only Steam data and are never cached to disk beyond the session.

## 10c. Community emulator profiles (import, sources, updates)

An emulator profile is a JSON file (`schemas/emulator-profile.v1.schema.json`, extension `.kouch-profile`) the community writes and shares. **The user adds the emulator; Kouch never bundles one.** A profile therefore carries, besides the launch settings, an optional `source` block:

```json
"source": {
  "kind": "github_release",
  "repo": "owner/repo",
  "asset": "*-win64.zip",
  "url": "https://example.invalid/emulator.zip",
  "sha256": "…",
  "install_subdir": "emu",
  "executable": "emu.exe",
  "auto_update": false
}
```

`kind` is `github_release` (uses `repo` + `asset`, a glob over release asset names; the checksum comes from the release's checksum asset when present, else the download is shown as "unverified") or `direct_url` (uses `url` + a required `sha256`; https only). `install_subdir` is where the archive unpacks inside `<data>/emulators/<profile-id>/<version>/`; `executable` (relative to it) becomes `launch.path`. `auto_update` is a suggestion only — the user's toggle wins and defaults to off.

A profile is the **whole** launch contract, not just a download pointer: `launch`, `args` (with `{rom}` etc.), `window_mode` (fullscreen/borderless), `pause`, `quit`, `hotkeys` (save/load state, reset, screenshot), per-player DSU bindings, `native_gamepads`, `systems`/`extensions` — everything the frontend needs to run and drive that emulator (owner directive 2026-09-18). A profile that leaves any of these out is still valid (defaults apply) but a community profile is expected to fill them.

* **Import** (§6.11): drag a `.kouch-profile` onto the window, open one from Settings > Emulators > "Import profile", or a `kouch://import-profile?url=<https url>` / `kouch://add-emulator?…` link. Every import ends in the same confirmation Sheet: profile name, systems, and either the launch path or the **live-resolved** download source (host, the release version and asset name it will fetch, size, SHA-256 or "unverified"; if the source can't be reached the reason is shown and the button is disabled), plus the two toggles **"Check for emulator updates"** and **"Update automatically"** (both default **off**; a profile cannot turn them on). A = Import (plain profile) / **Install** (sourced profile), B = Cancel. Links never carry launch arguments; paths are validated (absolute, no traversal, no UNC); URL length capped.
* **Install on import** (owner decision 2026-09-18: "it will point to a source and download it"): a sourced profile is saved and its emulator is downloaded, verified and unpacked as part of the import — the Sheet closes on A and the download queue shows progress. A failed install keeps the saved profile ("Not installed yet") for a retry via Update.
* **Install / update** happens in Kouch: download over HTTPS only (the archive download follows the host's CDN redirect; integrity is the SHA-256, preferring the asset's own `<asset>.sha256`, then a release-wide manifest), unpack into `<data>/emulators/<profile-id>/<version>/` (format from the asset name, else the file's magic bytes), then atomically switch the profile's `launch.path`; the previous version stays for **Roll back** (Settings > Emulators > profile > Roll back). Never touches an emulator that is currently running; shows the release notes before an update when the source provides them.
* **Download queue** (owner directive 2026-09-18): every install — import, Update, background auto-update — enters one queue and runs **one at a time**; the rest wait as "Queued". Phases: queued → checking source → downloading (bytes of total, whole percent) → verifying checksum → unpacking → installed / failed (reason). The queue is listed above the profiles in Settings > Emulators as `DownloadRow`s (name, phase, accent progress bar — transform-only; an indeterminate sweep for the byte-less phases — bytes/version/reason, Dismiss when settled), and the status strip shows a compact bar + "Downloading 42%" / "N downloads · 42%" while anything is active. Results also land as toasts. The event carries the whole queue so a screen mounted mid-transfer is never out of date.
* **Settings > Emulators** row shows: name, systems, source (host or "Local"), installed version or "Not installed yet", and "Update available" when the source reports a newer release. Actions: Edit, Check now / Update, Roll back, Remove.
* The profile ships **no ROMs, BIOS, firmware or keys** and may not name paths to them (ground rule 2); the confirmation Sheet refuses profiles whose args or env reference such files.

### 10c.1 Emulator user data, Steam Cloud, LAN transfer and LAN play

Owner decisions 2026-09-18.

**One file type: `.kod`** (owner decision 2026-09-19, replacing the earlier `.koe`/`.koes`/`.kom`/`.koms` set). A `.kod` is a zip: `kod.json` (a name and a table of contents), `profiles/<id>.json` (one or more emulator profiles), optional `data/<id>/<folder>/…` (a profile's saves / states / screenshots / config — **never `system`**: the writer skips it and the reader refuses any such entry), and a reserved `mods/` tree. What a file *is* is derived from its contents, never stored: one profile → an emulator, several → a bundle, any data → a backup, anything under `mods/` → refused with "not supported yet". The import Sheet reads the kind and adapts: a bundle lists every emulator with one set of toggles and "Install all"; a backup adds "Restore these folders" toggles; a mod-bearing file offers only Cancel. Settings › Emulators › **Export…** writes a `.kod` of every profile plus its data folders (system excluded) to a path the user picks — the file-shaped twin of the LAN transfer, sharing its manifest and exclusion code. A hand-written profile is just the JSON document saved as `.kod`; Kouch tells a zip from a JSON by content, never by name.

**Emulator data folder.** Every profile carries a `user_data` block, prefilled by default: `folders` — `saves`, `states`, `screenshots`, `config`, `system` — each a sub-folder of `<Emulator data folder>/<profile id>/`; `cloud` — which of those may go to Steam Cloud (never `system`); `config_edits` — the edits Kouch applies to the emulator's own config before every launch so it uses those folders and the DSU input (`file` templated under `{emulator_dir}`/`{user_data}`/`{profile_dir}`; `format` ini/json/toml/text; the original is backed up as `<file>.kouch-backup` before the first edit; Settings > Emulators > "Restore emulator config" puts it back). The data folder defaults to `<data>/userdata` and is relocatable in Settings > Paths ("Emulator data folder"). Extra template placeholders: `{user_data}`, `{emulator_dir}`, `{saves}`, `{states}`, `{screenshots}`, `{config}`, `{system}`.

**Steam Cloud — first choice for saves.** Per profile, on by default ("Sync to Steam Cloud", ADR 0018; the user can turn it off per emulator; until Kouch has its own app id the toggle says "Syncs once Kouch is on Steam"): the `cloud` folders sync to Steam Cloud under Kouch's app id, per signed-in user, on game exit and via "Sync now"; `settings.cloud_cap_mb` (default 200) caps each profile; files over Steam's 100 MB per-write limit are skipped with a warning. Under the borrowed dev app id the write is guarded off (it would land in another game's cloud slot).

**LAN transfer.** Settings > Transfer: two Kouch PCs on the same local network move **data folders (never `system`), installed emulators and profiles** between them. Off by default; discovery (mDNS `_kouch._tcp`) and listening only while the screen is open; LAN-only address checks; first contact pairs with a 6-digit code shown on the receiver and typed on the sender (SPAKE2 → session key, ChaCha20-Poly1305 stream); paired PCs are remembered by id; the receiver picks what to accept; the receiver refuses any manifest entry under `system/` even from a hostile sender; progress row + toast. Nothing leaves the network.

**The `system` folder.** Where the **user** places firmware/BIOS/keys. Kouch never reads, lists, sends, receives, uploads or references it — not to Steam Cloud, not over LAN, not to any cloud drive (the owner asked for Drive/Dropbox linking and then LAN P2P for these; both declined: ground rule 2, the 2023 precedent of a well-known emulator being blocked from Steam over a decryption key in its binary, and LAN pairing can't prove one owner). Kouch learns exactly one bit about it — "exists and non-empty" — to show "System folder is empty — copy your own files in" plus an "Open system folder" button, which makes the one manual copy a 30-second job.

**LAN play (Mode A, controller forwarding).** A PC **hosts** a session (runs the emulator as usual); a paired guest PC **joins**, and its controllers stream to the host as a `LanSource` in `kouch-input` — remote pads take seats on the host's slot table through the normal claim flow (LED, motion, rumble back to the guest) and go out over DSU like local pads, so it works with every emulator with no netplay support. Encrypted UDP per tick on the session key, sequence-numbered, drop-old; ≤ 2 ms added on a wired LAN. The Transfer screen shows "Host a session" / "Join session" / "Leave"; the overlay Players panel labels forwarded pads "Remote (LAN)". **Mode B** — emulator-native netplay through `config_edits` + game-hash/version checks (FEATURE\_PLAN §6 "per-emulator netplay adapters") — is the follow-up, not built.

## 10d. Game folders (multiple per system)

Settings > Paths is a list of **folder rows**, any number. Each row: path, and a **system** chosen from the systems the installed profiles declare (or "Any — detect by extension"). Several rows may name the same system; one folder may be listed under several systems. The library scanner walks every row and tags each file with that row's system (extension-detected when "Any"); a file reachable under two rows keeps both, and the tile launches with the profile of whichever system the user picks in the detail sheet. Adding a row asks for the folder (native picker where available, text field otherwise) then the system. Removing a row does not delete files, only their library entries.

## 10e. Connect screen and device silhouettes (Plan v2 Phase 3)

`screens/Claim.svelte` is the connect screen, also used as the Players screen.

**Boxes.** It shows one box per seated controller, keyed by device handle:

* **Top:** player chip + four LED dots.
* **Middle:** the device silhouette.
* **Bottom:** power (battery % / Wired / LAN) and the motion badge.

**Layout by seat count:**

| Seats | Layout                                                                                    |
| ----- | ----------------------------------------------------------------------------------------- |
| 0     | Hero: an empty card with four player squares, plus "No controller connected" and Continue |
| 1     | The same hero, titled "Controller setup"                                                  |
| 2–4   | One row                                                                                   |
| 5–8   | A real 2 × 4 grid (rows are left-aligned, so the focus grid matches what you see)         |

Eight seats at most (ADR 0026): 2 × 4 is the largest layout and nothing pages. With all eight seats taken, a pad that connects gets no box; the header shows "All 8 seats are taken. A controller joins when a seat frees up." until someone leaves.

**The waiting square.** Every pad that is connected but holds no seat shares **one** square, counted as one box in the layouts above, so the layout only changes when someone actually joins:

* Its silhouette is outline-only (stroke, no fill), so it reads as "not joined yet" beside the filled player boxes.
* With two or more pads waiting it cycles about once a second, in connect order. The pad's outline crossfades (opacity only), and the Join pill's glyph (that pad's own bottom face button) changes in step, so the glyph always matches the shape shown (with the default Steam Controller glyphs it is the same bottom button on every pad). Reduced motion steps without the crossfade.
* A pad that joins pops in as its own box. The square stays, cycling one fewer.
* Nobody waiting: from two seated players until all 8 seats are taken, the square is an open dashed "Connect a controller" slot. In the hero states (nobody seated, or one seated) there is no open slot, and with nobody seated but a pad waiting, the hero card is the square.

**Animation.** A seat change is a **pop**, not a slide (owner 2026-09-28; `lib/flip.ts` `popKeys`/`popOut`/`popIn`, transform and opacity only):

* The boxes that must move or resize (1 → a row of 4, a row → 2 × 4, the gap closing after a leave) pop out where they are (scale 1 → 0.85 + fade, `--k-dur-pop-out` 120 ms, `--k-ease-in`), staggered `--k-pop-stagger` (20 ms) along each row (the rows run side by side).
* The layout changes while they're gone, and they pop back in at their new places (0.85 → 1 + fade, `--k-dur-pop-in` 180 ms, `--k-ease-spring`), same stagger. Each box's animation starts and ends with the whole wave and holds until its turn (a delay per box cost the Deck \~10 fps). Both ends come from `lib/connectLayout.ts`, never a layout read.
* A box never scales unevenly, so its silhouette and labels never stretch.
* Boxes that keep their place and size don't animate.
* A pad that joins pops in (scale 0.85 → 1 + fade), after the moved ones.
* A leaving box shrinks out (CSS, `.is-leaving`) before the gap closes.
* `players:changed` bursts coalesce into one batch; a change that arrives while boxes are out runs next.
* Turning a page still slides (the FLIP).
* Reduced motion skips all of it.

**Buttons:**

| Button | Who        | Action                                                                     |
| ------ | ---------- | -------------------------------------------------------------------------- |
| A      | new pad    | joins (the input thread seats it in claim mode) — on any screen, see below |
| A      | host       | Done                                                                       |
| X      | any player | motion on/off for their own seat                                           |
| Y      | host       | Reorder mode: A picks up / drops, X removes a seat, Y exits                |
| Start  | host       | "Controller not connecting?" help sheet                                    |
| B      | host       | Back                                                                       |

**Joining from any screen.** Claim mode is on whenever no game runs (`lib/joins.ts`), not only here: a connected pad that holds no seat presses A on Home, the Library, Settings, a game page or the wizard and takes the next free seat. Its held A is swallowed (the nav decoder ignores buttons already down when a seat starts), so the join press never also activates what is focused. Each new player gets the player-joined cue and a short "Player N joined" toast (not on this screen, whose new box already says it). While a game runs, seats change from the Quick Menu's Players panel instead: a stray A there belongs to the game.

**Cursors and silhouettes.**

* A player's cursor resting on their own box draws no ring. The host's ring (accent) does, and so does anyone visiting another box (in the visitor's color).
* Silhouettes are white while waiting and in the player's color once seated.

**The Quick Menu's Players panel** (§10f) draws the same boxes (chip + LEDs, silhouette in the seat's colour, power and motion), two to a row, with the same cursor rule: each player's ring in their own colour, none on your own box, the host's in the accent.

**DEV (mock transport only).** `+`/`=` adds a mock pad, Shift `+` adds three in one tick, and `-` removes the last one.

**Silhouettes and drive icons** are in-house, generated by `app/ui/scripts/gen-silhouettes.mjs` into `src/assets/{devices,drives}/`.

* **They draw the device class realistically, never a product.** Rule 1 applies to them in full, and ADR 0009's exception covers third-party Workshop themes only. So:
  * grips, bumpers and triggers, stick caps, the d-pad, four face buttons, menu and home buttons, touch surfaces, and the right stick layout for the class (offset or symmetric);
  * face buttons are plain circles: no letters or symbols, no centre +/- marks;
  * no maker's exact outline, logo, screenshot/home squares or pairing dots.
* **One colour, three layers.** Every shape is `fill="currentColor"`: a half-strength back layer (triggers, a ring), the body with a thin gap cut around every control (even-odd, so the background shows through in Black and White), and the controls filled back in. A screen's glass is a faint layer of its own.
* **Two sizes.** `<name>.svg` is the full drawing (the connect screen, the Quick Menu's Players panel). `<name>.sm.svg` is a bold version with no thin gaps for rows and widgets (`DeviceIcon`, `silhouetteSvg(name, "small")`).
* The waiting square strokes the full drawing's outlines (`.is-outline`).
* Neutral file names only.
* Device classes map in `lib/devices.ts`.
* Themes may replace them (ADR 0009).

## 10f. Social, widgets, Library parts, the Quick Menu and At a glance

Rules for the pieces built on the redesign (HOME\_LIBRARY\_PLAN §3.6, §4.6–4.8, §5). Everything here follows §4: the stage, whole pixels, the prompt-bar band, and one gliding ring per region (§4.1).

### Social (HOME\_LIBRARY\_PLAN §5)

* **Friend sheet** (`screens/social/FriendSheet.svelte`): full height, from the right. It shows:

  * the friend's picture, presence and status line;
  * when they're in a Kouch lobby, the lobby card: game, seats, and the checks against this machine (game, emulator, mods), run *before* Join;
  * **Played together**: the time you've played together and the games;
  * rows for Join, Invite, Message and Profile on Steam.

  A row that can't run says why in its second line; it never hides.
* **Chat sheet** (`ChatSheet.svelte`): two views in one sheet.

  * The conversations: this session's first, then friends online.
  * A thread: the messages, then a text row (Steam's floating keyboard, else Kouch's own), then "Open in Steam chat".

  Messages live for the session only, like Steam's in-game chat. Unread counts show on the chat widget and in quick access.
* **Presence words** (`lib/social.ts` `statusLine`): "Hosting X", "Playing X · Mika's lobby", "Playing X", "In a game", "Online", "Away · 2 h", "Online 3 d ago", "Offline". A Kouch lobby outranks Steam's own presence. A friend's name is the persona name.
* **Played together** measures time together; peers' own hours need lobby member data (§2.6), so friend dots on the Playtime bars sit at the time played together.

### Widgets (HOME\_LIBRARY\_PLAN §3.6)

Each widget is a `WidgetDef` (`home/widgets/types.ts`): sizes S, M, T (tall), L; `focus: "single"` (the whole widget is one item) or `"list"` (rows inside, one ring over them); config fields shown in Edit Home; `available` hides one that can't work here (Battery on a desktop).

* **One reason, one action.** Every empty or broken state is one line and, when there's something to do, one action: "Nobody's online · X Invite someone", "Steam is off for Kouch", "No captures yet", "microSD card (E:) is out · 38 games greyed".
* **A opens where the widget points** (Players, Downloads, Settings › Storage, Transfer or Emulators, a game page, the friend or chat sheet). **X** is the widget's own second action, and only then is it in the prompt bar: invite, update all, pick another, open the game page.
* **Rows fill the widget.** A list widget plans how many rows fit its height (`socialWidgets.ts planFriendRows`), and the last row stacks the rest ("Bo, Jo and 3 more"). It never shows half a row, and it never leaves a gap bigger than a row.
* **Glances** (the folded rail's 64 px squares) show one number or one picture: players seated, the lowest battery, unread messages, invite countdowns, free space.
* **Contents are top-aligned** inside the widget (no centring into an odd leftover). Avatars, dots and icons use `--k-avatar-*`, `--k-dot*` and `--k-icon-*` so they land on even pixels.
* **Large text.** A widget's root takes `use:fitDown` (`lib/fitDown.ts`). When its content outgrows the frame (150 % text on a small tile), the root's own type and spacing tokens shrink, rounded, down to 0.6. Past that it hides the parts marked `data-fit-optional` ("1" first: an empty state's icon, a row of dots; then "2": a second line). The number or the one line that matters always stays.
* **Colour is never the only carrier, and coloured text passes 4.5:1.** Presence is a coloured dot; the status words stay `--k-text-2`. Warnings use `--k-warn` for dots and icons and `--k-warn-text` for words; it is darker in White (5.35:1 on `--k-surface-4`).

### Library parts (HOME\_LIBRARY\_PLAN §4.6–4.8)

* **Collections section.** Groups in order: Yours, Shared with you, From tags (fill themselves), Emulators (always last). Each is a grid of three tiles across (416 : 300, `SystemTile`); the attribution goes in the tile's status line ("shared by Mika · updated yesterday"). "+ New collection" is the last of Yours, dashed; "Add an emulator" is the last of Emulators.
  * A opens a collection or an emulator (a breadcrumb, then the games; the missing items are listed dashed and never downloaded).
  * X copies a shared list to yours, **shares** one of yours (`.kod`, only the list), or updates an emulator.
  * Y edits one of yours or opens the emulator's settings.
  * B returns to the exact tile. A first row scrolls in with its heading whole (`scroll-padding`), and the ring always has room.
* **Emulator card** (`EmulatorCard`): a numbered swatch, name, the systems it runs, and its state: ok, "ready to update" (X), an update bar, "needs something" in `--k-warn-text`.
* **Rule editor** (`CollectionEditor`): the rules read as one sentence ("Games tagged Racing and Co-op, for 2+ players, on any system, never played"). Each part steps with left/right (A steps forward), X removes a tag, and "+ And another tag" adds one. Under the sentence, the live count ("11 games match · from 3 systems") updates with a strip of covers. Y saves; B cancels.
* **Tags sheet** (`TagsSheet`, LF20), from the game page (Y) or a card's menu. It has three parts:

  * "From its info · filled in with the art": A hides or shows an info tag again (struck through; never deleted);
  * "Your tags": A removes one; add one with the controller keyboard;
  * "In these collections": each with why ("added by hand", "because of the tag Racing").

  Focus lands on the first chip, or the new-tag field when there are none.
* **Playtime section.**
  * **Header:** the total, then "this week", then the When / System / Emulator chips (View / Y / X step them), then the stacked bar with its legend. Small shares merge into "Other".
  * **Rows:** 60 px each (`PlaytimeRow`): icon, name, system · emulator in its own column, the bar with friend dots, hours, last played, star. Hours without a last-played show "—", never "never played".
  * **List:** the name and where columns are capped so the bar starts right after them. The list ends on a whole row above the prompt bar, and pooled rows outside the viewport are transparent, so none shows through the ring's padding.
* **Shared collections in and out.** Opening a friend's `.kod` shows "shared by Rin · 5 games · you have 3 of them" and "Only the list comes in… Kouch never downloads games." Share (X) asks for a full path, then shows where it was saved.

### The in-game Quick Menu (`screens/overlay/`)

* **Its own stage.** The overlay window is only the panel, so `overlay.html` sets `data-window="overlay"`. Its type then follows the *screen's* stage, `min(2.222vh, 4.085vw)`, where 4.085vw is the screen's 1.389vw at a 0.34 share, not the narrow window's. The menu fills its window. In the main window's dev dock it is `--k-panel-w` wide.
* **Layout, top to bottom:**
  * Now playing: the game's art, name, and "System · on for 42 min", from `session:state`;
  * the seats as the connect screen's squares, with a dot for motion;
  * the rows;
  * Quit game (full width, held);
  * the prompts, bottom right, B rightmost.
* **Rows** are the Start menu's (`MenuItem`): icon, label, and a second line saying what's inside ("Save, load, screenshot, reset") or why it's off ("Coming later", "Start online play from the game's page"). They share the room down to Quit, each growing to a cap, so the panel has no hole under them.
* **Sub-panels** push their own trapped scope; B pops one level.
  * **Players** is the connect screen's boxes, two to a row, sharing the height (§10e).
  * **Game** and **Controller tools** are two-column action boxes (`ActionTile`: a big line icon, label, optional line). The one destructive action sits under them as a full-width HoldButton. Controller tools leads with the pad it acts on.
  * These groups are `free` (spatial), so moving down from either column reaches the held button.

### At a glance (the game page)

A plate beside the tab body, as tall as it, filled from the top:

* time played (big, "Not played yet");
* last played and plays;
* developer · year, players, "Finished", "Has saves";
* up to six lines of the description;
* tags: the game's own and the genres, once each, then "Edit tags".

It takes no focus: Y opens the tags sheet, and "Edit tags" is its pointer shortcut.

## 11. Do / Don't

| Do                                                                               | Don't                                                                          |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Use `--k-*` tokens for every color, size, radius, duration                       | Write a hex, `rgb()`, `hsl()`, px size or ms outside `tokens.css`              |
| Express elevation with the next surface step + hairline                          | Use `box-shadow`, `text-shadow`, `drop-shadow`, `backdrop-filter`, `gradient(` |
| Animate `transform` and `opacity` with `--k-dur-*` / `--k-ease-*`                | Animate width/height/top/left/margin/border/color                              |
| Register every focusable with the focus manager                                  | Rely on `:hover`, `tabindex`, or native `<button>` focus alone                 |
| Show glyph + label prompts for every available action                            | Hide an action behind a gesture, hover or right-click                          |
| Put strings in `en.json` in sentence case                                        | Inline prose in markup; Title Case; brand names                                |
| Test at 1280×800, 1920×1080, 3840×2160 and 150% text scale                       | Assume 1080p                                                                   |
| Use the player palette only for player identity                                  | Derive player colors from the accent or use them decoratively                  |
| Convey state with a glyph or text plus color                                     | Use color alone                                                                |
| Keep `will-change: transform` only on the currently focused tile and moving page | Leave `will-change` on every tile                                              |
| Glide one ring across a grid or shelf; cards take focus state instantly (§4.1)   | Transition each card's own scale/ring on focus in a grid or shelf              |

## 12. Compliance checklist (every PR touching `app/ui/`)

Reviewer answers yes/no; any "no" blocks merge.

1. `npm run design-lint` passes (no raw colors outside `theme/tokens.css`, no `gradient(`, `box-shadow`, `text-shadow`, `drop-shadow`, `backdrop-filter`, no `font-family` outside `theme/fonts.css`, transitions/animations only on `transform`/`opacity`).
2. `npm run brand-lint` passes against `docs/brand-blocklist.txt` for `en.json`, `.svelte`, `.ts`, `.css`, and no new third-party icon, font, sound or image was added without a license note in `docs/THIRD_PARTY.md`.
3. Every new user-facing string is in `app/ui/src/i18n/en.json`, sentence case, "motion" not "gyro", no brand names.
4. Every new focusable element is registered via `use:focusable` with a group; no raw `tabindex`, no `:hover` rules, no click handler without a focus item.
5. Every new screen/sheet declares its focus groups, exits, wrap policy and initial focus; B pops exactly one level; focus is restored after close.
6. Every available action is shown in the prompt bar as glyph + label; contextual X/Y prompts appear only when active. Prompts go through `PromptBar` (same spot, canonical order, B rightmost), never a screen's own row (§7).
7. Destructive actions use `HoldButton` (≥ 1000 ms) and can be cancelled.
8. All font sizes use `--k-fs-*`; nothing essential is below `--k-fs-sm`; interactive targets ≥ `--k-target-min`.
9. Accent and `--k-danger` appear only in the §2.2 lists; player colors appear only in `PlayerChip`, player focus rings and LED swatches; no other chroma.
10. Any new text/background pairing is ≥ 4.5:1 (`node scripts/contrast.mjs #fg #bg` output pasted in the PR).
11. Animations use `--k-dur-*`/`--k-ease-*`, are transform/opacity only (WAAPI keyframes included — no `transform-origin` or other property in a keyframe; never `composite: add`/`accumulate`, which WebKitGTK runs on the main thread, repainting every frame: write the whole transform into each keyframe, design-lint `composite`), and were checked with `data-motion="reduced"`. Focus motion across a card grid or shelf is one gliding ring and the cards change state instantly (§4.1).
12. Screenshots attached at 1280×800, 1920×1080 and 3840×2160 (and at 150% text scale for new screens), showing default and focused states.
13. New full-screen surfaces pad with `--k-safe-x`/`--k-safe-y`.
14. Empty state and error state implemented for any new data-driven view.
15. Debug HUD `nav→tick` p95 ≤ 4 ms on the touched screens and no layout work inside transition frames (Web Inspector / DevTools timeline screenshot for grid or page changes).
16. No new runtime dependency without a bundle-size delta in the PR description; total JS ≤ 250 KB gzip.
17. Rust-owned tokens (`--k-accent`, `--k-safe-*`, `--k-text-scale`) are not hard-coded in the UI.
18. Both windows (main and overlay) still build and the overlay is click-through when hidden (manual check noted in PR).
19. `fit:` and `align:` (§4 Checks) pass on every touched screen at the §4 sizes, in Black and White. White is set through settings (`settings_set {display: {scheme: "white"}}` on the mock), so the accent is re-gated as in the app; `data-scheme` alone is not a White check. The Quick Menu is checked in `overlay.html` at panel sizes (34 % of the screen width × its height).
20. A sheet that ends in Cancel / confirm puts that row in `footer` (pinned under the list, primary rightmost), so a long sheet opens at the top with its action in view.
21. Keyboard alone and mouse alone reach and use everything (owner, 2026-09-29): `node scripts/ui-crawl.mjs --base <vite>` (headless, on the mock) visits every screen, layer and sheet it can open and fails on an item the arrow keys can't reach, a child Esc doesn't close, an item a click can't hit, or a child a click can't open or close; its report (`target/crawl/crawl.md`) also lists each transition's long tasks. 0 failures on the touched screens.


---

# 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/design.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.
