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

# Performance on the Steam Deck: the bar, how to measure, what we learned

WebKitGTK on the Deck is Kouch's slowest renderer, so it's the gate: Plan v2 Phase 1 kept the Rust + Tauri + Svelte stack on the condition that the Deck stays smooth. The rigs are in `docs/agents/TESTING.md` §6; this file covers numbers and method.

## The bar

* **85.5 fps average per tour phase at 90 Hz in Game Mode**, extracted AppImage tree, the tour's fixed friend list (`KOUCH_PERF_TOUR_FRIENDS=fake`).
* **Setup frames** (a view switch, opening a list) are logged apart as `PERF_TOUR phase=<name>_setup ms= max_frame=`. They're one-off, but a 100 ms frame is a visible hitch, so track them too.
* **The boot logo is exempt:** WebKitGTK animates SVG parts at a steady 60 Hz whatever the display rate.
* **Single runs vary by about ±1 fps.** Alternate builds (`new old new`) and compare averages; never judge on one run.

## The tour

`KOUCH_PERF_TOUR=1` makes the UI drive a fixed scenario (`app/ui/src/lib/perfTour.ts`, `perfTourPhases.ts`; the log side is `commands/perf.rs`). Each phase logs one `PERF_TOUR phase=… hz= fps= p50= p95= max= hitches= frames= pass=` line.

* **Phases:** boot, library\_systems, library\_az (+setup), library\_shelf (+setup), library\_playtime (+setup), home\_fold, home\_widgets, connect, ambient, friends\_list (+setup), sheet, boot\_replay, boot\_replay\_nomask.
* **Settings:** `_EXIT=1` quits when done, `_HZ=90` fixes the refresh it judges against, `_GAMES=500` caps the library, and `_FRIENDS=fake` swaps live friends for a fixed list (the first 8 wear pictures and animated items).
* **Test-data drift has fooled us for hours.** The tour opens the Library's remembered section and view, and at one point every stand-in title started with the same letter. If a number looks too good or too bad, check the test config's `library.section`/`views` first. The tour now resets itself, but keep an eye on it.

## Numbers (2026-09-30, build 1aaa1c7, two tours; Game Mode, 90 Hz, fake friends)

| Phase             | fps         | Note                                                                |
| ----------------- | ----------- | ------------------------------------------------------------------- |
| library\_systems  | 86.8 / 89.7 |                                                                     |
| library\_az       | 89.4 / 89.5 | the setup frame is \~45 ms                                          |
| library\_shelf    | 88.7 / 88.2 | the setup frame is \~100–106 ms: mount cost, unchanged by `72babfd` |
| library\_playtime | 89.0 / 86.8 | the setup frame is \~52–71 ms                                       |
| home\_fold        | 89.1 / 89.0 | was 83 until `composite:add` was dropped (`51143d5`)                |
| home\_widgets     | 89.6 / 89.6 |                                                                     |
| ambient           | 89.6 / 89.6 |                                                                     |
| friends\_list     | 87.7 / 87.2 | the scroller layer (`7bebd84`)                                      |
| sheet             | 87.5 / 86.0 | every sheet body scrolls on its own layer (`72babfd`)               |
| connect           | 83.9 / 83.7 | below the bar: layout work per pad join/leave (C, FINDINGS #12)     |

The A/Bs behind these: `72babfd` vs `c677e65` (the sheet layer, the Shelf's first pull) and `1aaa1c7` vs `72babfd` (Kenney icons, hand cursors, signal bars). Neither regressed anything beyond ±1 fps.

## How to find the cause

1. **Tour it** (`scripts/deck/deck-tour.sh`) to know which phase is short.
2. **A/B at runtime** (`scripts/deck/deck-ab.sh`) before writing code: inject CSS, set a data attribute, or monkeypatch an API and see whether the phase moves. It found home\_fold's cause: `composite:"add"` on `element.animate`.
3. **Profile it** (`scripts/deck/deck-profile.sh`) and read two things from the recording:
   * **JS:** `node scripts/perf/wk-map.mjs <json> <that build's dist/assets>` maps CPU samples to source lines. The build needs `KOUCH_SOURCEMAP=1`. WebKit's timeline has no timestamps (all 0), so sample order stands in for time.
   * **Paint:** `node scripts/perf/paint-rects.mjs <json>` counts Paint records by rect (the `data.clip` quad, CSS px) and gives frames / paints / layouts / style recalcs. When the JS share is small (under \~100 samples in 10 s), the cost is in style/layout/paint, and this is where it shows.

**Reading paint rects:**

* Rects that move between records are an animation WebKit isn't accelerating: an additive composite, a width/height animation, or a transform on an element without a layer.
* The same small rect at a fixed spot every frame is an animated image repainting its tile. Give the playing image its own layer (`12b7778`).
* A rect as tall as a whole list is a scroller without its own layer: each step repaints all its content. Put the scroller on a layer (`will-change: transform`, `overflow-anchor: none`; `7bebd84`).
* Many layouts and style recalcs with little paint mean DOM churn (connect's per-join re-layout of every box).

## Lessons already paid for (don't relearn them)

* **One gliding focus ring**, not per-card focus transitions (`c7b0257`, DESIGN.md §4.1).
* **WAAPI keyframes that animate only transform/opacity.** `@keyframes` are lint-banned.
* **No `composite: "add"`/`accumulate`:** WebKitGTK runs them on the main thread (design-lint refuses them, `51143d5`).
* **Stagger inside one animation's keyframes, never with a per-box `delay`:** 8 delayed boxes cost \~10 fps (`waveAnimate`).
* **Batch layout reads before writes** (`scheduleFit`, `lib/pixel.ts`). Don't read computed style mid-change; derive from the animation's own timing where possible (`3c26fb9`).
* **Avoid per-call `localeCompare` with options** in JavaScriptCore; use one `Intl.Collator`.
* **Writing `#k-theme` only when the CSS really changed** saves a full restyle on every settings save (`e648059`).
* **Hidden screens stop fitting;** a hidden Home stops re-fitting on seat changes.
* **A warm GStreamer registry per version and an audio keep-alive** keep sound from costing frames on the Deck.
* **The extracted AppImage tree starts \~0.5 s faster** than the squashfs image.
* **CSS `mask-image` costs on WebKitGTK:** masked pictures cost \~2 fps in lists and sheets and \~1 fps on the connect screen (a Deck A/B on 2026-09-30, Realistic vs Simple controller pictures). Draw single-colour art as inline SVG in `currentColor` instead of masks.
* **`contain: layout style` on the children doesn't stop a relayout driven from their parent:** find what invalidates the container first.


---

# 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/agents/perf.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.
